diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..b15e402 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,21 @@ +name: Lint + +# Static checks only -- no browser build, so these can gate every pull request. +# The build workflow runs on tags and takes ~40 minutes; nothing was checking +# pull requests before this. +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +jobs: + input-dispatch: + name: Synthesized input goes through one chokepoint + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: python3 scripts/check-input-dispatch.py diff --git a/additions/camoucfg/MaskConfig.hpp b/additions/camoucfg/MaskConfig.hpp index ebd6a14..813323f 100644 --- a/additions/camoucfg/MaskConfig.hpp +++ b/additions/camoucfg/MaskConfig.hpp @@ -7,6 +7,7 @@ Written by daijro. #include "json.hpp" #include #include +#include #include #include #include @@ -116,6 +117,31 @@ inline std::vector GetStringListLower(const std::string& key) { return result; } +/** + * The spoofed font family allowlist ("fonts"), lowercased and cached for the + * lifetime of the process. CAMOU_CONFIG is read once at startup and never + * changes, and the gfx font lookup paths consult this on every family + * resolution, so re-parsing the JSON per call is not an option. + * An empty list means no font spoofing is configured. + */ +inline const std::vector& FontAllowlist() { + static const std::vector fonts = GetStringListLower("fonts"); + return fonts; +} + +inline bool HasFontAllowlist() { return !FontAllowlist().empty(); } + +/** + * Whether a font family may be used. `family` must already be lowercased + * (gfxPlatformFontList::GenerateFontListKey output is). Always true when no + * allowlist is configured. + */ +inline bool IsFontAllowed(std::string_view family) { + const auto& fonts = FontAllowlist(); + if (fonts.empty()) return true; + return std::find(fonts.begin(), fonts.end(), family) != fonts.end(); +} + template inline std::optional GetUintImpl(const std::string& key) { const auto& data = GetJson(); diff --git a/additions/juggler/Helper.js b/additions/juggler/Helper.js index 875dd5a..598d7ea 100644 --- a/additions/juggler/Helper.js +++ b/additions/juggler/Helper.js @@ -3,6 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ const uuidGen = Cc["@mozilla.org/uuid-generator;1"].getService(Ci.nsIUUIDGenerator); +const {setTimeout, clearTimeout} = ChromeUtils.importESModule("resource://gre/modules/Timer.sys.mjs"); export class Helper { decorateAsEventEmitter(objectToDecorate) { @@ -172,6 +173,8 @@ export class Helper { const helper = new Helper(); +const kEventTimedOut = Symbol('event-timed-out'); + export class EventWatcher { constructor(receiver, eventNames, pendingEventWatchers = new Set()) { this._pendingEventWatchers = pendingEventWatchers; @@ -202,6 +205,35 @@ export class EventWatcher { } } + /** + * Like ensureEvent, but gives up after timeoutMs and resolves null instead of + * waiting forever. + * + * Callers awaiting an ack for synthesized input must use this. Input dispatch + * is serialized on activateAndRun()'s process-global promise chain, so an ack + * that never arrives does not merely lose one event -- it wedges every later + * input event in the process, in every tab, permanently, at 0% CPU with no + * diagnostic. Four shipped deadlocks (#225, #677, #751, #752) were all that + * failure. Bounding the wait is what makes the fifth one a log line. + */ + async ensureEventWithin(aEventName, timeoutMs, predicate) { + const pending = this.ensureEvent(aEventName, predicate); + // Whichever promise loses the race stays pending until dispose() rejects + // it; swallow that so a timed-out wait never surfaces as an unhandled + // rejection in chrome JS. + pending.catch(() => {}); + let timer; + const timedOut = new Promise(resolve => { + timer = setTimeout(() => resolve(kEventTimedOut), timeoutMs); + }); + try { + const result = await Promise.race([pending, timedOut]); + return result === kEventTimedOut ? null : result; + } finally { + clearTimeout(timer); + } + } + async ensureEvents(eventNames, predicate) { if (!Array.isArray(eventNames)) throw new Error('ERROR: ensureEvents expects an array of event names as its first argument'); diff --git a/additions/juggler/TargetRegistry.js b/additions/juggler/TargetRegistry.js index 746d475..3e29748 100644 --- a/additions/juggler/TargetRegistry.js +++ b/additions/juggler/TargetRegistry.js @@ -11,6 +11,23 @@ const {AppConstants} = ChromeUtils.importESModule("resource://gre/modules/AppCon // scripts), so the screencast tick has to import them explicitly. const {setTimeout, clearTimeout} = ChromeUtils.importESModule("resource://gre/modules/Timer.sys.mjs"); +// Last-resort bound on how long one callback may occupy the process-global +// activation chain below. The chain must always advance: a callback that never +// returns wedges every later input event in every tab, permanently. +// +// The per-ack deadline in MouseDispatch.js covers the await that has actually +// caused all four shipped deadlocks, but it is one of several unbounded waits +// reachable from a single slot -- apz-repaints-flushed, TabSwitchDone below, +// the drag path's juggler-drag-finalized and dragover waits, and the +// cross-process dispatchDragEvent sends all have the same shape. None of them +// has failed yet. Bounding only the wait that has already bitten us is the +// posture that produced those four fixes, so bound the slot itself too. +// +// Sized as a backstop, not a tuning knob: with a 5s ack deadline a legitimate +// worst-case input slot approaches 10s, so this must sit well clear of that. +const kActivationSlotBudgetMs = 30000; +const kSlotExpired = Symbol('activation-slot-expired'); + const Cr = Components.results; const helper = new Helper(); @@ -512,7 +529,18 @@ export class PageTarget { const notificationsPopup = muteNotificationsPopup ? this._linkedBrowser?.ownerDocument.getElementById('notification-popup') : null; notificationsPopup?.style.setProperty('pointer-events', 'none'); try { - await callback(); + let timer; + const expired = new Promise(resolve => { + timer = setTimeout(() => resolve(kSlotExpired), kActivationSlotBudgetMs); + }); + try { + if (await Promise.race([callback(), expired]) === kSlotExpired) { + dump(`[juggler] WARN activation-chain slot exceeded ` + + `${kActivationSlotBudgetMs}ms; advancing the chain without it\n`); + } + } finally { + clearTimeout(timer); + } } finally { notificationsPopup?.style.removeProperty('pointer-events'); } diff --git a/additions/juggler/TargetRegistry.js.bak b/additions/juggler/TargetRegistry.js.bak deleted file mode 100644 index 7ea6f93..0000000 --- a/additions/juggler/TargetRegistry.js.bak +++ /dev/null @@ -1,1306 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -const {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 = 'JUGGLER '; -const HUNDRED_YEARS = 60 * 60 * 24 * 365 * 100; - -const ALL_PERMISSIONS = [ - 'geo', - 'desktop-notification', -]; - -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) { - this._registry = registry - this._handlerToUuid = new Map(); - this._uuidToHandler = new Map(); - } - - // - // nsIDownloadInterceptor implementation. - // - interceptDownloadRequest(externalAppHandler, request, browsingContext, outFile) { - if (!(request instanceof Ci.nsIChannel)) - return false; - const channel = request.QueryInterface(Ci.nsIChannel); - let pageTarget = this._registry._browserIdToTarget.get(channel.loadInfo.browsingContext.top.browserId); - if (!pageTarget) - return false; - - const browserContext = pageTarget.browserContext(); - const options = browserContext.downloadOptions; - if (!options) - return false; - - const uuid = helper.generateId(); - let file = null; - if (options.behavior === 'saveToDisk') { - file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); - file.initWithPath(options.downloadsDir); - file.append(uuid); - - try { - file.create(Ci.nsIFile.NORMAL_FILE_TYPE, 0o600); - } catch (e) { - dump(`WARNING: interceptDownloadRequest failed to create file: ${e}`); - return false; - } - } - outFile.value = file; - this._handlerToUuid.set(externalAppHandler, uuid); - this._uuidToHandler.set(uuid, externalAppHandler); - const downloadInfo = { - uuid, - browserContextId: browserContext.browserContextId, - pageTargetId: pageTarget.id(), - frameId: helper.browsingContextToFrameId(channel.loadInfo.browsingContext), - url: request.name, - suggestedFileName: externalAppHandler.suggestedFileName, - }; - this._registry.emit(TargetRegistry.Events.DownloadCreated, downloadInfo); - return true; - } - - onDownloadComplete(externalAppHandler, canceled, errorName) { - const uuid = this._handlerToUuid.get(externalAppHandler); - if (!uuid) - return; - this._handlerToUuid.delete(externalAppHandler); - this._uuidToHandler.delete(uuid); - const downloadInfo = { - uuid, - error: errorName, - }; - if (canceled === 'NS_BINDING_ABORTED') { - downloadInfo.canceled = true; - } - this._registry.emit(TargetRegistry.Events.DownloadFinished, downloadInfo); - } - - async cancelDownload(uuid) { - const externalAppHandler = this._uuidToHandler.get(uuid); - if (!externalAppHandler) { - return; - } - await externalAppHandler.cancel(Cr.NS_BINDING_ABORTED); - } -} - -const screencastService = Cc['@mozilla.org/juggler/screencast;1'].getService(Ci.nsIScreencastService); - -export class TargetRegistry { - static instance() { - return TargetRegistry._instance || null; - } - - constructor() { - helper.decorateAsEventEmitter(this); - TargetRegistry._instance = this; - - this._browserContextIdToBrowserContext = new Map(); - this._userContextIdToBrowserContext = new Map(); - this._browserToTarget = new Map(); - this._browserIdToTarget = new Map(); - - this._proxiesWithClashingAuthCacheKeys = new Set(); - this._browserProxy = null; - - // Cleanup containers from previous runs (if any) - for (const identity of ContextualIdentityService.getPublicIdentities()) { - if (identity.name && identity.name.startsWith(IDENTITY_NAME)) { - ContextualIdentityService.remove(identity.userContextId); - ContextualIdentityService.closeContainerTabs(identity.userContextId); - } - } - - this._defaultContext = new BrowserContext(this, undefined, undefined); - - Services.obs.addObserver({ - observe: (subject, topic, data) => { - const browser = subject.ownerElement; - if (!browser) - return; - const target = this._browserToTarget.get(browser); - if (!target) - return; - target.emit(PageTarget.Events.Crashed); - target.dispose(); - } - }, 'oop-frameloader-crashed'); - - const onTabOpenListener = (appWindow, window, event) => { - const tab = event.target; - const userContextId = tab.userContextId; - const browserContext = this._userContextIdToBrowserContext.get(userContextId); - const hasExplicitSize = appWindow && (appWindow.chromeFlags & Ci.nsIWebBrowserChrome.JUGGLER_WINDOW_EXPLICIT_SIZE) !== 0; - const openerContext = tab.linkedBrowser.browsingContext.opener; - let openerTarget; - if (openerContext) { - // Popups usually have opener context. Get top context for the case when opener is - // an iframe. - openerTarget = this._browserIdToTarget.get(openerContext.top.browserId); - } else if (tab.openerTab) { - // Noopener popups from the same window have opener tab instead. - openerTarget = this._browserToTarget.get(tab.openerTab.linkedBrowser); - } - if (!browserContext) - throw new Error(`Internal error: cannot find context for userContextId=${userContextId}`); - const target = new PageTarget(this, window, tab, browserContext, openerTarget); - target.updateOverridesForBrowsingContext(tab.linkedBrowser.browsingContext); - if (!hasExplicitSize) - target.updateViewportSize(); - if (browserContext.videoRecordingOptions) - target._startVideoRecording(browserContext.videoRecordingOptions); - }; - - const onTabCloseListener = event => { - const tab = event.target; - const linkedBrowser = tab.linkedBrowser; - const target = this._browserToTarget.get(linkedBrowser); - if (target) - target.dispose(); - }; - - const domWindowTabListeners = new Map(); - - const onOpenWindow = async (appWindow) => { - - let domWindow; - if (appWindow instanceof Ci.nsIAppWindow) { - domWindow = appWindow.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindowInternal || Ci.nsIDOMWindow); - } else { - domWindow = appWindow; - appWindow = null; - } - if (!domWindow.isChromeWindow) - return; - // In persistent mode, window might be opened long ago and might be - // already initialized. - // - // In this case, we want to keep this callback synchronous so that we will call - // `onTabOpenListener` synchronously and before the sync IPc message `juggler:content-ready`. - if (domWindow.document.readyState === 'uninitialized' || domWindow.document.readyState === 'loading') { - // For non-initialized windows, DOMContentLoaded initializes gBrowser - // and starts tab loading (see //browser/base/content/browser.js), so we - // are guaranteed to call `onTabOpenListener` before the sync IPC message - // `juggler:content-ready`. - await helper.awaitEvent(domWindow, 'DOMContentLoaded'); - } - - if (!domWindow.gBrowser) - return; - const tabContainer = domWindow.gBrowser.tabContainer; - domWindowTabListeners.set(domWindow, [ - helper.addEventListener(tabContainer, 'TabOpen', event => onTabOpenListener(appWindow, domWindow, event)), - helper.addEventListener(tabContainer, 'TabClose', onTabCloseListener), - ]); - for (const tab of domWindow.gBrowser.tabs) - onTabOpenListener(appWindow, domWindow, { target: tab }); - }; - - const onCloseWindow = window => { - const domWindow = window.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindowInternal || Ci.nsIDOMWindow); - if (!domWindow.isChromeWindow) - return; - if (!domWindow.gBrowser) - return; - - const listeners = domWindowTabListeners.get(domWindow) || []; - domWindowTabListeners.delete(domWindow); - helper.removeListeners(listeners); - for (const tab of domWindow.gBrowser.tabs) - onTabCloseListener({ target: tab }); - }; - - const extHelperAppSvc = Cc["@mozilla.org/uriloader/external-helper-app-service;1"].getService(Ci.nsIExternalHelperAppService); - this._downloadInterceptor = new DownloadInterceptor(this); - extHelperAppSvc.setDownloadInterceptor(this._downloadInterceptor); - - Services.wm.addListener({ onOpenWindow, onCloseWindow }); - for (const win of Services.wm.getEnumerator(null)) - onOpenWindow(win); - } - - // Firefox uses nsHttpAuthCache to cache authentication to the proxy. - // If we're provided with a single proxy with a multiple different authentications, then - // we should clear the nsHttpAuthCache on every request. - shouldBustHTTPAuthCacheForProxy(proxy) { - return this._proxiesWithClashingAuthCacheKeys.has(proxy); - } - - _updateProxiesWithSameAuthCacheAndDifferentCredentials() { - const proxyIdToCredentials = new Map(); - const allProxies = [...this._browserContextIdToBrowserContext.values()].map(bc => bc._proxy).filter(Boolean); - if (this._browserProxy) - allProxies.push(this._browserProxy); - const proxyAuthCacheKeyAndProxy = allProxies.map(proxy => [ - JSON.stringify({ - type: proxy.type, - host: proxy.host, - port: proxy.port, - }), - proxy, - ]); - this._proxiesWithClashingAuthCacheKeys.clear(); - - proxyAuthCacheKeyAndProxy.sort(([cacheKey1], [cacheKey2]) => cacheKey1 < cacheKey2 ? -1 : 1); - for (let i = 0; i < proxyAuthCacheKeyAndProxy.length - 1; ++i) { - const [cacheKey1, proxy1] = proxyAuthCacheKeyAndProxy[i]; - const [cacheKey2, proxy2] = proxyAuthCacheKeyAndProxy[i + 1]; - if (cacheKey1 !== cacheKey2) - continue; - if (proxy1.username === proxy2.username && proxy1.password === proxy2.password) - continue; - // `proxy1` and `proxy2` have the same caching key, but serve different credentials. - // We have to bust HTTP Auth Cache everytime there's a request that will use either of the proxies. - this._proxiesWithClashingAuthCacheKeys.add(proxy1); - this._proxiesWithClashingAuthCacheKeys.add(proxy2); - } - } - - async cancelDownload(options) { - this._downloadInterceptor.cancelDownload(options.uuid); - } - - setBrowserProxy(proxy) { - this._browserProxy = proxy; - this._updateProxiesWithSameAuthCacheAndDifferentCredentials(); - } - - getProxyInfo(channel) { - const originAttributes = channel.loadInfo && channel.loadInfo.originAttributes; - const browserContext = originAttributes ? this.browserContextForUserContextId(originAttributes.userContextId) : null; - // Prefer context proxy and fallback to browser-level proxy. - const proxyInfo = (browserContext && browserContext._proxy) || this._browserProxy; - if (!proxyInfo || proxyInfo.bypass.some(domainSuffix => channel.URI.host.endsWith(domainSuffix))) - return null; - return proxyInfo; - } - - defaultContext() { - return this._defaultContext; - } - - createBrowserContext(removeOnDetach) { - return new BrowserContext(this, helper.generateId(), removeOnDetach); - } - - browserContextForId(browserContextId) { - return this._browserContextIdToBrowserContext.get(browserContextId); - } - - browserContextForUserContextId(userContextId) { - return this._userContextIdToBrowserContext.get(userContextId); - } - - 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}) { - console.error(`[TR-DEBUG] _newPageInternal start ctx=${browserContextId}`); - const browserContext = this.browserContextForId(browserContextId); - const features = "chrome,dialog=no,all"; - // See _callWithURIToLoad in browser.js for the structure of window.arguments - // window.arguments[1]: unused (bug 871161) - // [2]: referrerInfo (nsIReferrerInfo) - // [3]: postData (nsIInputStream) - // [4]: allowThirdPartyFixup (bool) - // [5]: userContextId (int) - // [6]: originPrincipal (nsIPrincipal) - // [7]: originStoragePrincipal (nsIPrincipal) - // [8]: triggeringPrincipal (nsIPrincipal) - // [9]: allowInheritPrincipal (bool) - // [10]: csp (nsIContentSecurityPolicy) - // [11]: nsOpenWindowInfo - const args = Cc["@mozilla.org/array;1"].createInstance(Ci.nsIMutableArray); - const urlSupports = Cc["@mozilla.org/supports-string;1"].createInstance( - Ci.nsISupportsString - ); - urlSupports.data = 'about:blank'; - args.appendElement(urlSupports); // 0 - args.appendElement(undefined); // 1 - args.appendElement(undefined); // 2 - args.appendElement(undefined); // 3 - args.appendElement(undefined); // 4 - const userContextIdSupports = Cc[ - "@mozilla.org/supports-PRUint32;1" - ].createInstance(Ci.nsISupportsPRUint32); - userContextIdSupports.data = browserContext.userContextId; - args.appendElement(userContextIdSupports); // 5 - args.appendElement(undefined); // 6 - args.appendElement(undefined); // 7 - args.appendElement(Services.scriptSecurityManager.getSystemPrincipal()); // 8 - - console.error(`[TR-DEBUG] opening window with url=${AppConstants.BROWSER_CHROME_URL}`); - const window = Services.ww.openWindow(null, AppConstants.BROWSER_CHROME_URL, '_blank', features, args); - console.error(`[TR-DEBUG] openWindow returned, awaiting ready`); - await waitForWindowReady(window); - console.error(`[TR-DEBUG] window ready, browsers=${window.gBrowser ? window.gBrowser.browsers.length : 'no gBrowser'}`); - if (window.gBrowser.browsers.length !== 1) - throw new Error(`Unexpected number of tabs in the new window: ${window.gBrowser.browsers.length}`); - const browser = window.gBrowser.browsers[0]; - let target = this._browserToTarget.get(browser); - console.error(`[TR-DEBUG] initial target lookup: ${target ? 'found' : 'not found'}`); - let attempts = 0; - while (!target) { - attempts++; - console.error(`[TR-DEBUG] awaiting TargetCreated event (attempt ${attempts})`); - await helper.awaitEvent(this, TargetRegistry.Events.TargetCreated); - target = this._browserToTarget.get(browser); - console.error(`[TR-DEBUG] after TargetCreated: ${target ? 'matched' : 'not matched'}`); - } - browser.focus(); - if (browserContext.crossProcessCookie.settings.timezoneId) { - if (await target.hasFailedToOverrideTimezone()) - throw new Error('Failed to override timezone'); - } - didCreateFirstPage = true; - return target.id(); - } - - targets() { - return Array.from(this._browserToTarget.values()); - } - - targetForBrowser(browser) { - return this._browserToTarget.get(browser); - } - - targetForBrowserId(browserId) { - return this._browserIdToTarget.get(browserId); - } -} - -export class PageTarget { - constructor(registry, win, tab, browserContext, opener) { - helper.decorateAsEventEmitter(this); - - this._targetId = helper.generateId(); - this._registry = registry; - this._window = win; - this._gBrowser = win.gBrowser; - this._tab = tab; - this._linkedBrowser = tab.linkedBrowser; - this._browserContext = browserContext; - this._viewportSize = undefined; - this._zoom = 1; - this._initialDPPX = this._linkedBrowser.browsingContext.overrideDPPX; - this._url = 'about:blank'; - this._openerId = opener ? opener.id() : undefined; - this._actor = undefined; - this._actorSequenceNumber = 0; - this._channel = new SimpleChannel(`browser::page[${this._targetId}]`, 'target-' + this._targetId); - this._videoRecordingInfo = undefined; - this._screencastRecordingInfo = undefined; - this._dialogs = new Map(); - this.forcedColors = 'none'; - this.disableCache = false; - this.mediumOverride = ''; - this.crossProcessCookie = { - initScripts: [], - bindings: [], - interceptFileChooserDialog: false, - }; - - const navigationListener = { - QueryInterface: ChromeUtils.generateQI([Ci.nsIWebProgressListener, Ci.nsISupportsWeakReference]), - onLocationChange: (aWebProgress, aRequest, aLocation) => this._onNavigated(aLocation), - }; - this._eventListeners = [ - helper.addObserver(this._updateModalDialogs.bind(this), 'common-dialog-loaded'), - helper.addProgressListener(tab.linkedBrowser, navigationListener, Ci.nsIWebProgress.NOTIFY_LOCATION), - helper.addEventListener(this._linkedBrowser, 'DOMModalDialogClosed', event => this._updateModalDialogs()), - helper.addEventListener(this._linkedBrowser, 'WillChangeBrowserRemoteness', event => this._willChangeBrowserRemoteness()), - ]; - - this._disposed = false; - browserContext.pages.add(this); - this._registry._browserToTarget.set(this._linkedBrowser, this); - this._registry._browserIdToTarget.set(this._linkedBrowser.browsingContext.browserId, this); - - this._registry.emit(TargetRegistry.Events.TargetCreated, this); - } - - async activateAndRun(callback = () => {}, { muteNotificationsPopup = false } = {}) { - const ownerWindow = this._tab.linkedBrowser.ownerGlobal; - const tabBrowser = ownerWindow.gBrowser; - // Serialize all tab-switching commands per tabbed browser - // to disallow concurrent tab switching. - const result = globalTabAndWindowActivationChain.then(async () => { - this._window.focus(); - if (tabBrowser.selectedTab !== this._tab) { - const promise = helper.awaitEvent(ownerWindow, 'TabSwitchDone'); - tabBrowser.selectedTab = this._tab; - await promise; - } - const notificationsPopup = muteNotificationsPopup ? this._linkedBrowser?.ownerDocument.getElementById('notification-popup') : null; - notificationsPopup?.style.setProperty('pointer-events', 'none'); - try { - await callback(); - } finally { - notificationsPopup?.style.removeProperty('pointer-events'); - } - }); - globalTabAndWindowActivationChain = result.catch(error => { /* swallow errors to keep chain running */ }); - return result; - } - - frameIdToBrowsingContext(frameId) { - return helper.collectAllBrowsingContexts(this._linkedBrowser.browsingContext).find(bc => helper.browsingContextToFrameId(bc) === frameId); - } - - nextActorSequenceNumber() { - return ++this._actorSequenceNumber; - } - - setActor(actor) { - this._actor = actor; - this._channel.bindToActor(actor); - } - - removeActor(actor) { - // Note: the order between setActor and removeActor is non-deterministic. - // Therefore we check that we are still bound to the actor that is being removed. - if (this._actor !== actor) - return; - this._actor = undefined; - this._channel.resetTransport(); - } - - _willChangeBrowserRemoteness() { - this.removeActor(this._actor); - } - - dialog(dialogId) { - return this._dialogs.get(dialogId); - } - - dialogs() { - return [...this._dialogs.values()]; - } - - async windowReady() { - await waitForWindowReady(this._window); - } - - linkedBrowser() { - return this._linkedBrowser; - } - - browserContext() { - return this._browserContext; - } - - updateOverridesForBrowsingContext(browsingContext = undefined) { - this.updateTouchOverride(browsingContext); - 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); - } - - updateForceOffline(browsingContext = undefined) { - (browsingContext || this._linkedBrowser.browsingContext).forceOffline = this._browserContext.forceOffline; - } - - setCacheDisabled(disabled) { - this.disableCache = disabled; - this.updateCacheDisabled(); - } - - updateCacheDisabled(browsingContext = this._linkedBrowser.browsingContext) { - const enableFlags = Ci.nsIRequest.LOAD_NORMAL; - const disableFlags = Ci.nsIRequest.LOAD_BYPASS_CACHE | - Ci.nsIRequest.INHIBIT_CACHING; - - browsingContext.defaultLoadFlags = (this._browserContext.disableCache || this.disableCache) ? disableFlags : enableFlags; - } - - updateTouchOverride(browsingContext = undefined) { - (browsingContext || this._linkedBrowser.browsingContext).touchEventsOverride = this._browserContext.touchOverride ? 'enabled' : 'none'; - } - - updateUserAgent(browsingContext = undefined) { - (browsingContext || this._linkedBrowser.browsingContext).customUserAgent = this._browserContext.defaultUserAgent; - } - - updatePlatform(browsingContext = undefined) { - (browsingContext || this._linkedBrowser.browsingContext).customPlatform = this._browserContext.defaultPlatform; - } - - updateDPPXOverride(browsingContext = undefined) { - 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() { - const prompts = new Set(this._linkedBrowser.tabDialogBox.getContentDialogManager().dialogs.map(dialog => dialog.frameContentWindow.Dialog)); - for (const dialog of this._dialogs.values()) { - if (!prompts.has(dialog.prompt())) { - this._dialogs.delete(dialog.id()); - this.emit(PageTarget.Events.DialogClosed, dialog); - } else { - prompts.delete(dialog.prompt()); - } - } - for (const prompt of prompts) { - const dialog = Dialog.createIfSupported(prompt); - if (!dialog) - continue; - this._dialogs.set(dialog.id(), dialog); - this.emit(PageTarget.Events.DialogOpened, dialog); - } - } - - async updateViewportSize() { - await waitForWindowReady(this._window); - this.updateDPPXOverride(); - - // Viewport size is defined by three arguments: - // 1. default size. Could be explicit if set as part of `window.open` call, e.g. - // `window.open(url, title, 'width=400,height=400')` - // 2. page viewport size - // 3. browserContext viewport size - // - // The "default size" (1) is only respected when the page is opened. - // Otherwise, explicitly set page viewport prevales over browser context - // default viewport. - const viewportSize = this._viewportSize || this._browserContext.defaultViewportSize; - if (viewportSize) { - const {width, height} = viewportSize; - this._linkedBrowser.style.setProperty('width', width + 'px'); - this._linkedBrowser.style.setProperty('height', height + 'px'); - this._linkedBrowser.style.setProperty('box-sizing', 'content-box'); - this._linkedBrowser.closest('.browserStack').style.setProperty('overflow', 'auto'); - this._linkedBrowser.closest('.browserStack').style.setProperty('contain', 'size'); - this._linkedBrowser.closest('.browserStack').style.setProperty('scrollbar-width', 'none'); - this._linkedBrowser.browsingContext.inRDMPane = true; - - const stackRect = this._linkedBrowser.closest('.browserStack').getBoundingClientRect(); - const toolbarTop = stackRect.y; - this._window.resizeBy(width - this._window.innerWidth, height + toolbarTop - this._window.innerHeight); - - 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'); - this._linkedBrowser.style.removeProperty('box-sizing'); - this._linkedBrowser.closest('.browserStack').style.removeProperty('overflow'); - this._linkedBrowser.closest('.browserStack').style.removeProperty('contain'); - this._linkedBrowser.closest('.browserStack').style.removeProperty('scrollbar-width'); - this._linkedBrowser.browsingContext.inRDMPane = false; - - const actualSize = this._linkedBrowser.getBoundingClientRect(); - await this._channel.connect('').send('awaitViewportDimensions', { - width: actualSize.width / this._zoom, - height: actualSize.height / this._zoom, - }); - } - } - - setEmulatedMedia(mediumOverride) { - this.mediumOverride = mediumOverride || ''; - this.updateEmulatedMedia(); - } - - updateEmulatedMedia(browsingContext = undefined) { - (browsingContext || this._linkedBrowser.browsingContext).mediumOverride = this.mediumOverride; - } - - setColorScheme(colorScheme) { - this.colorScheme = fromProtocolColorScheme(colorScheme); - this.updateColorSchemeOverride(); - } - - updateColorSchemeOverride(browsingContext = undefined) { - (browsingContext || this._linkedBrowser.browsingContext).prefersColorSchemeOverride = this.colorScheme || this._browserContext.colorScheme || 'none'; - } - - setReducedMotion(reducedMotion) { - this.reducedMotion = fromProtocolReducedMotion(reducedMotion); - this.updateReducedMotionOverride(); - } - - updateReducedMotionOverride(browsingContext = undefined) { - (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(); - } - - updateForcedColorsOverride(browsingContext = undefined) { - const isActive = this.forcedColors === 'active' || this._browserContext.forcedColors === 'active'; - (browsingContext || this._linkedBrowser.browsingContext).forcedColorsOverride = isActive ? 'active' : 'none'; - } - - async setInterceptFileChooserDialog(enabled) { - this.crossProcessCookie.interceptFileChooserDialog = enabled; - this._updateCrossProcessCookie(); - await this._channel.connect('').send('setInterceptFileChooserDialog', enabled).catch(e => {}); - } - - async setViewportSize(viewportSize) { - this._viewportSize = viewportSize; - 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, - }); - } - - channel() { - return this._channel; - } - - id() { - return this._targetId; - } - - info() { - return { - targetId: this.id(), - type: 'page', - browserContextId: this._browserContext.browserContextId, - openerId: this._openerId, - }; - } - - _onNavigated(aLocation) { - this._url = aLocation.spec; - this._browserContext.grantPermissionsToOrigin(this._url); - } - - _updateCrossProcessCookie() { - Services.ppmm.sharedData.set('juggler:page-cookie-' + this._linkedBrowser.browsingContext.browserId, this.crossProcessCookie); - Services.ppmm.sharedData.flush(); - } - - async ensurePermissions() { - await this._channel.connect('').send('ensurePermissions', {}).catch(e => void e); - } - - async setInitScripts(scripts) { - this.crossProcessCookie.initScripts = scripts; - this._updateCrossProcessCookie(); - await this.pushInitScripts(); - } - - async pushInitScripts() { - await this._channel.connect('').send('setInitScripts', [...this._browserContext.crossProcessCookie.initScripts, ...this.crossProcessCookie.initScripts]).catch(e => void e); - } - - async addBinding(worldName, name, script) { - this.crossProcessCookie.bindings.push({ worldName, name, script }); - this._updateCrossProcessCookie(); - await this._channel.connect('').send('addBinding', { worldName, name, script }).catch(e => void e); - } - - async applyContextSetting(name, value) { - await this._channel.connect('').send('applyContextSetting', { name, value }).catch(e => void e); - } - - async hasFailedToOverrideTimezone() { - return await this._channel.connect('').send('hasFailedToOverrideTimezone').catch(e => true); - } - - async _startVideoRecording({width, height, dir}) { - // On Mac the window may not yet be visible when TargetCreated and its - // NSWindow.windowNumber may be -1, so we wait until the window is known - // to be initialized and visible. - await this.windowReady(); - const file = PathUtils.join(dir, helper.generateId() + '.webm'); - if (width < 10 || width > 10000 || height < 10 || height > 10000) - throw new Error("Invalid size"); - - const docShell = this._gBrowser.ownerGlobal.docShell; - // Exclude address bar and navigation control from the video. - const rect = this.linkedBrowser().getBoundingClientRect(); - const devicePixelRatio = this._window.devicePixelRatio; - let sessionId; - const registry = this._registry; - const screencastClient = { - QueryInterface: ChromeUtils.generateQI([Ci.nsIScreencastServiceClient]), - screencastFrame(data, deviceWidth, deviceHeight) { - }, - screencastStopped() { - registry.emit(TargetRegistry.Events.ScreencastStopped, sessionId); - }, - }; - const viewport = this._viewportSize || this._browserContext.defaultViewportSize || { width: 0, height: 0 }; - sessionId = screencastService.startVideoRecording(screencastClient, docShell, true, file, width, height, 0, viewport.width, viewport.height, devicePixelRatio * rect.top); - this._videoRecordingInfo = { sessionId, file }; - this.emit(PageTarget.Events.ScreencastStarted); - } - - _stopVideoRecording() { - if (!this._videoRecordingInfo) - throw new Error('No video recording in progress'); - const videoRecordingInfo = this._videoRecordingInfo; - this._videoRecordingInfo = undefined; - screencastService.stopVideoRecording(videoRecordingInfo.sessionId); - } - - videoRecordingInfo() { - return this._videoRecordingInfo; - } - - async startScreencast({ width, height, quality }) { - // On Mac the window may not yet be visible when TargetCreated and its - // NSWindow.windowNumber may be -1, so we wait until the window is known - // to be initialized and visible. - await this.windowReady(); - if (width < 10 || width > 10000 || height < 10 || height > 10000) - throw new Error("Invalid size"); - - const docShell = this._gBrowser.ownerGlobal.docShell; - // Exclude address bar and navigation control from the video. - const rect = this.linkedBrowser().getBoundingClientRect(); - const devicePixelRatio = this._window.devicePixelRatio; - - const self = this; - const screencastClient = { - QueryInterface: ChromeUtils.generateQI([Ci.nsIScreencastServiceClient]), - screencastFrame(data, deviceWidth, deviceHeight) { - if (self._screencastRecordingInfo) - self.emit(PageTarget.Events.ScreencastFrame, { data, deviceWidth, deviceHeight }); - }, - screencastStopped() { - }, - }; - const viewport = this._viewportSize || this._browserContext.defaultViewportSize || { width: 0, height: 0 }; - const screencastId = screencastService.startVideoRecording(screencastClient, docShell, false, '', width, height, quality || 90, viewport.width, viewport.height, devicePixelRatio * rect.top); - this._screencastRecordingInfo = { screencastId }; - return { screencastId }; - } - - screencastFrameAck({ screencastId }) { - if (!this._screencastRecordingInfo || this._screencastRecordingInfo.screencastId !== screencastId) - return; - screencastService.screencastFrameAck(screencastId); - } - - stopScreencast() { - if (!this._screencastRecordingInfo) - throw new Error('No screencast in progress'); - const { screencastId } = this._screencastRecordingInfo; - this._screencastRecordingInfo = undefined; - screencastService.stopVideoRecording(screencastId); - } - - ensureContextMenuClosed() { - // Close context menu, if any, since it might capture mouse events on Linux - // and prevent browser shutdown on MacOS. - const doc = this._linkedBrowser.ownerDocument; - const contextMenu = doc.getElementById('contentAreaContextMenu'); - if (contextMenu) - contextMenu.hidePopup(); - const autocompletePopup = doc.getElementById('PopupAutoComplete'); - if (autocompletePopup) - autocompletePopup.hidePopup(); - const selectPopup = doc.getElementById('ContentSelectDropdown')?.menupopup; - if (selectPopup) - selectPopup.hidePopup() - } - - dispose() { - this.ensureContextMenuClosed(); - this._disposed = true; - if (this._videoRecordingInfo) - this._stopVideoRecording(); - if (this._screencastRecordingInfo) - this.stopScreencast(); - this._browserContext.pages.delete(this); - this._registry._browserToTarget.delete(this._linkedBrowser); - this._registry._browserIdToTarget.delete(this._linkedBrowser.browsingContext.browserId); - try { - helper.removeListeners(this._eventListeners); - } catch (e) { - // In some cases, removing listeners from this._linkedBrowser fails - // because it is already half-destroyed. - if (e) - dump(e.message + '\n' + e.stack + '\n'); - } - this._registry.emit(TargetRegistry.Events.TargetDestroyed, this); - } -} - -PageTarget.Events = { - ScreencastStarted: Symbol('PageTarget.ScreencastStarted'), - ScreencastFrame: Symbol('PageTarget.ScreencastFrame'), - Crashed: Symbol('PageTarget.Crashed'), - DialogOpened: Symbol('PageTarget.DialogOpened'), - DialogClosed: Symbol('PageTarget.DialogClosed'), -}; - -function fromProtocolColorScheme(colorScheme) { - if (colorScheme === 'light' || colorScheme === 'dark') - return colorScheme; - if (colorScheme === null || colorScheme === 'no-preference') - return undefined; - throw new Error('Unknown color scheme: ' + colorScheme); -} - -function fromProtocolReducedMotion(reducedMotion) { - if (reducedMotion === 'reduce' || reducedMotion === 'no-preference') - return reducedMotion; - if (reducedMotion === null) - return undefined; - 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; - if (!forcedColors) - return 'none'; - throw new Error('Unknown forced colors: ' + forcedColors); -} - -class BrowserContext { - constructor(registry, browserContextId, removeOnDetach) { - this._registry = registry; - this.browserContextId = browserContextId; - // Default context has userContextId === 0, but we pass undefined to many APIs just in case. - this.userContextId = 0; - if (browserContextId !== undefined) { - const identity = ContextualIdentityService.create(IDENTITY_NAME + browserContextId); - this.userContextId = identity.userContextId; - } - this._principals = []; - // Maps origins to the permission lists. - this._permissions = new Map(); - this._registry._browserContextIdToBrowserContext.set(this.browserContextId, this); - this._registry._userContextIdToBrowserContext.set(this.userContextId, this); - this._proxy = null; - this.removeOnDetach = removeOnDetach; - this.extraHTTPHeaders = undefined; - this.httpCredentials = undefined; - this.requestInterceptionEnabled = undefined; - this.ignoreHTTPSErrors = undefined; - this.downloadOptions = undefined; - this.defaultViewportSize = undefined; - this.deviceScaleFactor = undefined; - this.defaultUserAgent = null; - this.defaultPlatform = null; - this.touchOverride = false; - this.forceOffline = false; - this.disableCache = false; - this.colorScheme = 'none'; - this.forcedColors = 'none'; - this.reducedMotion = 'none'; - this.contrast = 'none'; - this.videoRecordingOptions = undefined; - this.crossProcessCookie = { - initScripts: [], - bindings: [], - settings: {}, - }; - this.pages = new Set(); - } - - _updateCrossProcessCookie() { - Services.ppmm.sharedData.set('juggler:context-cookie-' + this.userContextId, this.crossProcessCookie); - Services.ppmm.sharedData.flush(); - } - - setColorScheme(colorScheme) { - this.colorScheme = fromProtocolColorScheme(colorScheme); - for (const page of this.pages) - page.updateColorSchemeOverride(); - } - - setReducedMotion(reducedMotion) { - this.reducedMotion = fromProtocolReducedMotion(reducedMotion); - for (const page of this.pages) - 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) - page.updateForcedColorsOverride(); - } - - async destroy() { - if (this.userContextId !== 0) { - ContextualIdentityService.remove(this.userContextId); - for (const page of this.pages) - page.close(); - if (this.pages.size) { - await new Promise(f => { - const listener = helper.on(this._registry, TargetRegistry.Events.TargetDestroyed, () => { - if (!this.pages.size) { - helper.removeListeners([listener]); - f(); - } - }); - }); - } - } - this._registry._browserContextIdToBrowserContext.delete(this.browserContextId); - this._registry._userContextIdToBrowserContext.delete(this.userContextId); - this._registry._updateProxiesWithSameAuthCacheAndDifferentCredentials(); - } - - setProxy(proxy) { - // Clear AuthCache. - Services.obs.notifyObservers(null, "net:clear-active-logins"); - this._proxy = proxy; - this._registry._updateProxiesWithSameAuthCacheAndDifferentCredentials(); - } - - setIgnoreHTTPSErrors(ignoreHTTPSErrors) { - if (this.ignoreHTTPSErrors === ignoreHTTPSErrors) - return; - this.ignoreHTTPSErrors = ignoreHTTPSErrors; - const certOverrideService = Cc[ - "@mozilla.org/security/certoverride;1" - ].getService(Ci.nsICertOverrideService); - if (ignoreHTTPSErrors) { - Preferences.set("network.stricttransportsecurity.preloadlist", false); - Preferences.set("security.cert_pinning.enforcement_level", 0); - certOverrideService.setDisableAllSecurityChecksAndLetAttackersInterceptMyDataForUserContext(this.userContextId, true); - } else { - certOverrideService.setDisableAllSecurityChecksAndLetAttackersInterceptMyDataForUserContext(this.userContextId, false); - } - } - - setDefaultUserAgent(userAgent) { - this.defaultUserAgent = userAgent; - for (const page of this.pages) - page.updateUserAgent(); - } - - setDefaultPlatform(platform) { - this.defaultPlatform = platform; - for (const page of this.pages) - page.updatePlatform(); - } - - setTouchOverride(touchOverride) { - this.touchOverride = touchOverride; - for (const page of this.pages) - page.updateTouchOverride(); - } - - setForceOffline(forceOffline) { - this.forceOffline = forceOffline; - for (const page of this.pages) - page.updateForceOffline(); - } - - setCacheDisabled(disabled) { - this.disableCache = disabled; - for (const page of this.pages) - page.updateCacheDisabled(); - } - - async setDefaultViewport(viewport) { - this.defaultViewportSize = viewport ? viewport.viewportSize : undefined; - this.deviceScaleFactor = viewport ? viewport.deviceScaleFactor : undefined; - await Promise.all(Array.from(this.pages).map(page => page.updateViewportSize())); - } - - async setInitScripts(scripts) { - this.crossProcessCookie.initScripts = scripts; - this._updateCrossProcessCookie(); - await Promise.all(Array.from(this.pages).map(page => page.pushInitScripts())); - } - - async addBinding(worldName, name, script) { - this.crossProcessCookie.bindings.push({ worldName, name, script }); - this._updateCrossProcessCookie(); - await Promise.all(Array.from(this.pages).map(page => page.addBinding(worldName, name, script))); - } - - async applySetting(name, value) { - this.crossProcessCookie.settings[name] = value; - this._updateCrossProcessCookie(); - await Promise.all(Array.from(this.pages).map(page => page.applyContextSetting(name, value))); - } - - async grantPermissions(origin, permissions) { - this._permissions.set(origin, permissions); - const promises = []; - for (const page of this.pages) { - if (origin === '*' || page._url.startsWith(origin)) { - this.grantPermissionsToOrigin(page._url); - promises.push(page.ensurePermissions()); - } - } - await Promise.all(promises); - } - - resetPermissions() { - for (const principal of this._principals) { - for (const permission of ALL_PERMISSIONS) - Services.perms.removeFromPrincipal(principal, permission); - } - this._principals = []; - this._permissions.clear(); - } - - grantPermissionsToOrigin(url) { - let origin = Array.from(this._permissions.keys()).find(key => url.startsWith(key)); - if (!origin) - origin = '*'; - - const permissions = this._permissions.get(origin); - if (!permissions) - return; - - const attrs = { userContextId: this.userContextId || undefined }; - const principal = Services.scriptSecurityManager.createContentPrincipal(NetUtil.newURI(url), attrs); - this._principals.push(principal); - for (const permission of ALL_PERMISSIONS) { - const action = permissions.includes(permission) ? Ci.nsIPermissionManager.ALLOW_ACTION : Ci.nsIPermissionManager.DENY_ACTION; - Services.perms.addFromPrincipal(principal, permission, action, Ci.nsIPermissionManager.EXPIRE_NEVER, 0 /* expireTime */); - } - } - - setCookies(cookies) { - const protocolToSameSite = { - [undefined]: Ci.nsICookie.SAMESITE_UNSET, - 'None': Ci.nsICookie.SAMESITE_UNSET, - 'Lax': Ci.nsICookie.SAMESITE_LAX, - 'Strict': Ci.nsICookie.SAMESITE_STRICT, - }; - for (const cookie of cookies) { - const uri = cookie.url ? NetUtil.newURI(cookie.url) : null; - let domain = cookie.domain; - if (!domain) { - if (!uri) - throw new Error('At least one of the url and domain needs to be specified'); - domain = uri.host; - } - let path = cookie.path; - if (!path) - path = uri ? dirPath(uri.filePath) : '/'; - let secure = false; - if (cookie.secure !== undefined) - secure = cookie.secure; - else if (uri && uri.scheme === 'https') - secure = true; - Services.cookies.add( - domain, - path, - cookie.name, - cookie.value, - secure, - cookie.httpOnly || false, - cookie.expires === undefined || cookie.expires === -1 /* isSession */, - cookie.expires === undefined ? Date.now() + HUNDRED_YEARS : cookie.expires * 1000, - { userContextId: this.userContextId || undefined } /* originAttributes */, - protocolToSameSite[cookie.sameSite], - Ci.nsICookie.SCHEME_UNSET - ); - } - } - - clearCookies() { - Services.cookies.removeCookiesWithOriginAttributes(JSON.stringify({ userContextId: this.userContextId || undefined })); - } - - 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', - }; - for (let cookie of Services.cookies.cookies) { - if (cookie.originAttributes.userContextId !== this.userContextId) - continue; - if (cookie.host === 'addons.mozilla.org') - continue; - result.push({ - name: cookie.name, - value: cookie.value, - domain: cookie.host, - path: cookie.path, - expires: cookie.isSession ? -1 : cookie.expiry / 1000, - size: cookie.name.length + cookie.value.length, - httpOnly: cookie.isHttpOnly, - secure: cookie.isSecure, - session: cookie.isSession, - sameSite: sameSiteToProtocol[cookie.sameSite], - }); - } - return result; - } - - async setVideoRecordingOptions(options) { - this.videoRecordingOptions = options; - const promises = []; - for (const page of this.pages) { - if (options) - promises.push(page._startVideoRecording(options)); - else if (page._videoRecordingInfo) - promises.push(page._stopVideoRecording()); - } - await Promise.all(promises); - } -} - -class Dialog { - static createIfSupported(prompt) { - const type = prompt.args.promptType; - switch (type) { - case 'alert': - case 'alertCheck': - return new Dialog(prompt, 'alert'); - case 'prompt': - return new Dialog(prompt, 'prompt'); - case 'confirm': - case 'confirmCheck': - return new Dialog(prompt, 'confirm'); - case 'confirmEx': - return new Dialog(prompt, 'beforeunload'); - default: - return null; - }; - } - - constructor(prompt, type) { - this._id = helper.generateId(); - this._type = type; - this._prompt = prompt; - } - - id() { - return this._id; - } - - message() { - return this._prompt.ui.infoBody.textContent; - } - - type() { - return this._type; - } - - prompt() { - return this._prompt; - } - - dismiss() { - if (this._prompt.ui.button1) - this._prompt.ui.button1.click(); - else - this._prompt.ui.button0.click(); - } - - defaultValue() { - return this._prompt.ui.loginTextbox.value; - } - - accept(promptValue) { - if (typeof promptValue === 'string' && this._type === 'prompt') - this._prompt.ui.loginTextbox.value = promptValue; - this._prompt.ui.button0.click(); - } -} - - -function dirPath(path) { - return path.substring(0, path.lastIndexOf('/') + 1); -} - -async function waitForWindowReady(window) { - if (window.delayedStartupPromise) { - await window.delayedStartupPromise; - } else { - await new Promise((resolve => { - Services.obs.addObserver(function observer(aSubject, aTopic) { - if (window == aSubject) { - Services.obs.removeObserver(observer, aTopic); - resolve(); - } - }, "browser-delayed-startup-finished"); - })); - } - if (window.document.readyState !== 'complete') - await helper.awaitEvent(window, 'load'); -} - -TargetRegistry.Events = { - TargetCreated: Symbol('TargetRegistry.Events.TargetCreated'), - TargetDestroyed: Symbol('TargetRegistry.Events.TargetDestroyed'), - DownloadCreated: Symbol('TargetRegistry.Events.DownloadCreated'), - DownloadFinished: Symbol('TargetRegistry.Events.DownloadFinished'), - ScreencastStopped: Symbol('TargetRegistry.ScreencastStopped'), -}; diff --git a/additions/juggler/input/MouseDispatch.js b/additions/juggler/input/MouseDispatch.js new file mode 100644 index 0000000..613ca35 --- /dev/null +++ b/additions/juggler/input/MouseDispatch.js @@ -0,0 +1,229 @@ +/* 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"; + +/** + * The one place in the parent process that dispatches synthesized mouse input. + * + * It exists because the alternative did not work. Between 2026-04 and 2026-09, + * four separate deadlocks shipped -- exact-edge coordinates (#225), humanized + * trajectory points that bypassed the endpoint's guard (#677/#225), a + * zero-displacement move, and the top-edge row (#751/#752) -- each fixed by + * adding one more coordinate guard at one more call site. The guards were + * correct; the arithmetic behind them was copied per call site, so every new + * dispatch site was a fresh chance to get it wrong, and every mistake cost the + * whole browser process. + * + * THE INVARIANT + * A synthesized input event whose ack we await must reach the content + * renderer -- and when it does not, we must stop waiting. + * + * Nothing else in juggler may call jugglerSendMouseEvent / sendWheelEvent or do + * browser-relative coordinate arithmetic; scripts/check-input-dispatch.py fails + * the build if it does. See docs/input-dispatch.md. + */ + +const {setTimeout} = ChromeUtils.importESModule('resource://gre/modules/Timer.sys.mjs'); + +/** + * How long to wait for a juggler-mouse-event-hit-renderer ack before giving up. + * + * Measured on v152.0.4-beta.30, headless Linux, over 1000+ dispatches: + * + * content main thread p50 p99 max + * idle 0ms 1ms 12ms + * busy (8ms burned/event) 8ms 9ms 12ms + * blocked (3s sync script) 0ms 1ms 2849ms + * + * Typical latency is three orders of magnitude below this. The deadline is not + * sized by the typical case though: the ack is delivered FROM the content main + * thread, so it inherits any block on it, and block length is page-controlled + * and unbounded. Pages that block for seconds are the normal case on the + * targets Camoufox exists to handle. Sizing this near the p99 would silently + * drop real input on merely-slow pages -- a correctness bug wearing the exact + * costume of the deadlock it replaces. Waiting too long costs a few seconds + * once and then recovers; waiting too little costs input loss that nobody can + * diagnose. So: well above the slowest legitimate ack, not near the typical one. + */ +export const kAckDeadlineMs = 5000; + +/** Delay between humanized trajectory points, preserving the original cadence. */ +export const kTrajectoryStepMs = 10; + +function warnUndelivered(eventType, x, y, box, deadlineMs) { + dump( + `[juggler] WARN ${eventType} at (${x}, ${y}) was not delivered to the ` + + `renderer after ${deadlineMs}ms; dropping it (browser rect ` + + `${box.width}x${box.height} at +${box.left}+${box.top})\n` + ); +} + +export class MouseDispatch { + /** + * @param {Window} win chrome window owning the browser element. + * @param {DOMRect} boundingBox the browser element's rect, already measured. + * @param {object} eventArgs button / clickCount / modifiers / buttons. + */ + constructor(win, boundingBox, {button = 0, clickCount = 0, modifiers = 0, buttons = 0} = {}) { + this._win = win; + this._box = boundingBox; + this._args = {button, clickCount, modifiers, buttons}; + + // The first whole pixel inside the browser element on each axis. + // + // The element's origin is not pixel-aligned: the chrome above the content is + // a fractional number of CSS pixels tall, and how many depends on the spoofed + // OS (measured: windows 51.4, macos 53.1, linux 56.5). A relative coordinate + // of 0 therefore dispatches at absolute y == boundingBox.top exactly -- the + // content area's first, only partly covered row. The widget rounds that to a + // whole device row before hit-testing it, and wherever round(top) < top the + // rounded row still belongs to chrome, so the event fires as an exit event + // rather than eMouseMove and no ack is ever produced (#751, #752). + // + // Snapping onto ceil() keeps the point inside content pixel 0 while landing + // clear of the boundary. It is a sub-pixel shift and only ever affects the + // first row/column; boundingBox.left is normally a whole 0 and unaffected. + this._originX = Math.ceil(boundingBox.left); + this._originY = Math.ceil(boundingBox.top); + + // The far edge has the same problem, and the bounds check below cannot see + // it either. The element's height is fractional too -- measured, it is + // consistently 0.5 CSS px less than the innerHeight the page reports, so + // the page's last row is only half covered. A point in it dispatches at an + // absolute coordinate that rounds onto the row *past* the content, and is + // dropped exactly like the top-edge case. Deterministic: with the box at + // 1920x977.5 +0+56.5, relative y == 977 (innerHeight - 1, well inside the + // viewport as far as the page is concerned) dispatches at 1033.5, rounds to + // 1034, and the content ends at 1034. + // + // So clamp to the last whole pixel fully inside the element as well. Found + // by tests/patches/mouse-boundary-sweep.py, not by a report -- it predates + // this module and deadlocks a stock build. + this._limitX = Math.ceil(boundingBox.left + boundingBox.width) - 1; + this._limitY = Math.ceil(boundingBox.top + boundingBox.height) - 1; + } + + static forBrowser(win, linkedBrowser, eventArgs) { + return new MouseDispatch(win, linkedBrowser.getBoundingClientRect(), eventArgs); + } + + get boundingBox() { + return this._box; + } + + /** + * Is this relative point inside the content viewport at all? + * + * The far edges are exclusive: a point at exactly x == width or y == height + * lies on the opposite boundary row and fires as an exit event, so it must be + * treated as out-of-viewport rather than dispatched (#225). The near edges are + * inclusive -- 0 is a legitimate coordinate a caller may ask for, and the + * constructor's snap is what makes it safe to dispatch. + */ + isInViewport(x, y) { + return x >= 0 && y >= 0 && x < this._box.width && y < this._box.height; + } + + /** Relative point -> absolute, snapped clear of both of the element's edges. */ + toAbsolute(x, y) { + return { + x: Math.min(Math.max(x + this._box.left, this._originX), this._limitX), + y: Math.min(Math.max(y + this._box.top, this._originY), this._limitY), + }; + } + + _sendAbsolute(eventType, absX, absY) { + return this._win.windowUtils.jugglerSendMouseEvent( + eventType, + absX, + absY, + this._args.button, + this._args.clickCount, + this._args.modifiers, + false /* aIgnoreRootScrollFrame */, + 0.0 /* pressure */, + 0 /* inputSource */, + true /* isDOMEventSynthesized */, + false /* isWidgetEventSynthesized */, + this._args.buttons, + this._win.windowUtils.DEFAULT_MOUSE_POINTER_ID /* pointerIdentifier */, + false /* disablePointerEvent */ + ); + } + + /** + * Dispatch one event and wait for the renderer to ack it, under a deadline. + * + * Returns the ack event object, or null if none arrived in time -- in which + * case the event is dropped and a warning is logged. Never rejects and never + * waits forever: input dispatch is serialized on activateAndRun()'s + * process-global chain, so an unbounded wait here wedges every later input + * event in the process, in every tab, for the life of the browser. + */ + async sendAcked(watcher, eventType, x, y, deadlineMs = kAckDeadlineMs) { + const {x: absX, y: absY} = this.toAbsolute(x, y); + // This dispatches to the renderer synchronously. + const jugglerEventId = this._sendAbsolute(eventType, absX, absY); + const ack = await watcher.ensureEventWithin( + eventType, deadlineMs, eventObject => eventObject.jugglerEventId === jugglerEventId); + if (!ack) + warnUndelivered(eventType, x, y, this._box, deadlineMs); + return ack; + } + + /** + * Dispatch the intermediate points of a humanized trajectory. + * + * Points outside the viewport are skipped. Bounding each ack individually is + * not enough to bound the work: a curve is ~110 points dispatched inside a + * SINGLE activation-chain slot, so a curve riding a coordinate that cannot be + * delivered would spend 110 x the deadline there. Rather than a wall-clock + * budget -- which would false-fire on exactly the slow pages the deadline + * exists to tolerate -- the first undelivered point abandons the rest of the + * curve. Intermediate points are humanization garnish: if one did not reach + * the renderer the rest of that curve almost certainly will not either, and + * dropping them costs realism, not correctness. The caller still dispatches + * the real destination afterwards. + * + * @returns {boolean} true if the whole curve was delivered. + */ + async sendTrajectoryAcked(watcher, eventType, points, stepDelayMs = kTrajectoryStepMs) { + for (const [x, y] of points) { + if (!this.isInViewport(x, y)) + continue; + if (!await this.sendAcked(watcher, eventType, x, y)) + return false; + await new Promise(resolve => setTimeout(resolve, stepDelayMs)); + } + return true; + } + + /** + * Park the cursor off web content so hover effects clear. + * + * Deliberately dispatched at the chrome window's own origin rather than a + * content coordinate, and deliberately unacked: it never enters the renderer, + * so there is no ack to wait for. + */ + parkOffContent() { + this._sendAbsolute('mousemove', 0, 0); + } + + /** Wheel events take the same conversion; they are not acked. */ + sendWheel(x, y, {deltaX, deltaY, deltaZ, deltaMode, lineOrPageDeltaX, lineOrPageDeltaY}) { + const {x: absX, y: absY} = this.toAbsolute(x, y); + this._win.windowUtils.sendWheelEvent( + absX, + absY, + deltaX, + deltaY, + deltaZ, + deltaMode, + this._args.modifiers, + lineOrPageDeltaX, + lineOrPageDeltaY, + 0 /* options */); + } +} diff --git a/additions/juggler/jar.mn b/additions/juggler/jar.mn index dbb2499..9ed198b 100644 --- a/additions/juggler/jar.mn +++ b/additions/juggler/jar.mn @@ -8,6 +8,7 @@ juggler.jar: content/components/Juggler.js (components/Juggler.js) content/Helper.js (Helper.js) + content/input/MouseDispatch.js (input/MouseDispatch.js) content/NetworkObserver.js (NetworkObserver.js) content/ChannelEventSink.sys.mjs (ChannelEventSink.sys.mjs) content/TargetRegistry.js (TargetRegistry.js) diff --git a/additions/juggler/protocol/PageHandler.js b/additions/juggler/protocol/PageHandler.js index c76c5db..d10e8ee 100644 --- a/additions/juggler/protocol/PageHandler.js +++ b/additions/juggler/protocol/PageHandler.js @@ -9,6 +9,7 @@ const {NetUtil} = ChromeUtils.importESModule('resource://gre/modules/NetUtil.sys 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 {MouseDispatch} = ChromeUtils.importESModule('chrome://juggler/content/input/MouseDispatch.js'); const Cc = Components.classes; const Ci = Components.interfaces; @@ -517,65 +518,36 @@ export class PageHandler { async ['Page.dispatchMouseEvent']({type, x, y, button, clickCount, modifiers, buttons}) { const win = this._pageTarget._window; + const eventArgs = {button, clickCount, modifiers, buttons}; const sendEvents = async (types) => { // 1. Scroll element to the desired location first; the coordinates are relative to the element. this._pageTarget._linkedBrowser.scrollRectIntoViewIfNeeded(x, y, 0, 0); // 2. Get element's bounding box in the browser after the scroll is completed. - const boundingBox = this._pageTarget._linkedBrowser.getBoundingClientRect(); + // MouseDispatch owns every conversion from these relative coordinates to + // absolute ones, and every wait for a renderer ack. + const dispatch = MouseDispatch.forBrowser(win, this._pageTarget._linkedBrowser, eventArgs); // 3. Make sure compositor is flushed after scrolling. if (win.windowUtils.flushApzRepaints()) await helper.awaitTopic('apz-repaints-flushed'); const watcher = new EventWatcher(this._pageEventSink, types, this._pendingEventWatchers); - // Dispatch a single synthesized mouse event to the renderer and return a - // promise that resolves once the renderer acks it. - const sendOne = (eventType, eventX, eventY) => { - // This dispatches to the renderer synchronously. - const jugglerEventId = win.windowUtils.jugglerSendMouseEvent( - eventType, - eventX + boundingBox.left, - eventY + boundingBox.top, - button, - clickCount, - modifiers, - false /* aIgnoreRootScrollFrame */, - 0.0 /* pressure */, - 0 /* inputSource */, - true /* isDOMEventSynthesized */, - false /* isWidgetEventSynthesized */, - buttons, - win.windowUtils.DEFAULT_MOUSE_POINTER_ID /* pointerIdentifier */, - false /* disablePointerEvent */ - ); - return watcher.ensureEvent(eventType, eventObject => eventObject.jugglerEventId === jugglerEventId); - }; - const promises = []; - for (const type of types) { + for (const eventType of types) { // Camoufox: when humanize is enabled, expand a direct mousemove into a // human-like trajectory of intermediate mousemoves generated in C++ // (ChromeUtils.camouGetMouseTrajectory / MouseTrajectories.hpp). - if (type === 'mousemove' && ChromeUtils.camouGetBool('humanize', false)) { + if (eventType === 'mousemove' && ChromeUtils.camouGetBool('humanize', false)) { const trajectory = ChromeUtils.camouGetMouseTrajectory(this._lastTrackedPos.x, this._lastTrackedPos.y, x, y); - // Dispatch intermediate points sequentially with a short delay. The - // first/last pairs are skipped: the last pair is the exact + // The first and last pairs are skipped: the last pair is the exact // destination, which is dispatched explicitly below. - for (let i = 2; i < trajectory.length - 2; i += 2) { - const currentX = trajectory[i]; - const currentY = trajectory[i + 1]; - // Skip movement that is out of bounds. Must match the endpoint guard - // below (>=, not >): a point at exactly x==width or y==height fires as - // an exit event instead of eMouseMove, so the hit-renderer ack never - // arrives and every later input event hangs behind it forever. - if (currentX < 0 || currentY < 0 || currentX >= boundingBox.width || currentY >= boundingBox.height) - continue; - await sendOne('mousemove', currentX, currentY); - await new Promise(resolve => setTimeout(resolve, 10)); - } + const points = []; + for (let i = 2; i < trajectory.length - 2; i += 2) + points.push([trajectory[i], trajectory[i + 1]]); + await dispatch.sendTrajectoryAcked(watcher, 'mousemove', points); // Always finish exactly on the requested destination. - promises.push(sendOne('mousemove', x, y)); + promises.push(dispatch.sendAcked(watcher, 'mousemove', x, y)); } else { - promises.push(sendOne(type, x, y)); + promises.push(dispatch.sendAcked(watcher, eventType, x, y)); } } await Promise.all(promises); @@ -588,32 +560,15 @@ export class PageHandler { await this._pageTarget.activateAndRun(async () => { this._pageTarget.ensureContextMenuClosed(); // If someone asks us to dispatch mouse event outside of viewport, then we normally would drop it. - const boundingBox = this._pageTarget._linkedBrowser.getBoundingClientRect(); - // Treat exact-edge coordinates as out-of-viewport: a mousemove at x==width or y==height fires as an exit event instead of eMouseMove, so the hit-renderer signal never arrives and every later input event hangs behind it forever. - if (x < 0 || y < 0 || x >= boundingBox.width || y >= boundingBox.height) { + const dispatch = MouseDispatch.forBrowser(win, this._pageTarget._linkedBrowser, eventArgs); + if (!dispatch.isInViewport(x, y)) { if (type !== 'mousemove') return; // A special hack: if someone tries to do `mousemove` outside of // viewport coordinates, then move the mouse off from the Web Content. // This way we can eliminate all the hover effects. - // NOTE: since this won't go inside the renderer, there's no need to wait for ACK. - win.windowUtils.jugglerSendMouseEvent( - 'mousemove', - 0 /* x */, - 0 /* y */, - button, - clickCount, - modifiers, - false /* aIgnoreRootScrollFrame */, - 0.0 /* pressure */, - 0 /* inputSource */, - true /* isDOMEventSynthesized */, - false /* isWidgetEventSynthesized */, - buttons, - win.windowUtils.DEFAULT_MOUSE_POINTER_ID /* pointerIdentifier */, - false /* disablePointerEvent */ - ); + dispatch.parkOffContent(); return; } @@ -700,24 +655,22 @@ export class PageHandler { // 1. Scroll element to the desired location first; the coordinates are relative to the element. this._pageTarget._linkedBrowser.scrollRectIntoViewIfNeeded(x, y, 0, 0); // 2. Get element's bounding box in the browser after the scroll is completed. - const boundingBox = this._pageTarget._linkedBrowser.getBoundingClientRect(); - const win = this._pageTarget._window; + const dispatch = MouseDispatch.forBrowser(win, this._pageTarget._linkedBrowser, {modifiers}); // 3. Make sure compositor is flushed after scrolling. if (win.windowUtils.flushApzRepaints()) await helper.awaitTopic('apz-repaints-flushed'); - win.windowUtils.sendWheelEvent( - x + boundingBox.left, - y + boundingBox.top, + // Same conversion as a mouse event: a wheel at relative y == 0 would + // otherwise land on the chrome/content boundary and scroll the tab strip. + dispatch.sendWheel(x, y, { deltaX, deltaY, deltaZ, deltaMode, - modifiers, lineOrPageDeltaX, lineOrPageDeltaY, - 0 /* options */); + }); }, { muteNotificationsPopup: true }); } diff --git a/docs/input-dispatch.md b/docs/input-dispatch.md new file mode 100644 index 0000000..ad63e94 --- /dev/null +++ b/docs/input-dispatch.md @@ -0,0 +1,73 @@ +# Synthesized input dispatch + +Every synthesized mouse and wheel event in the parent process goes through +`additions/juggler/input/MouseDispatch.js`. `scripts/check-input-dispatch.py` +fails the build if anything else dispatches input or does browser-relative +coordinate arithmetic, and it runs on every pull request. + +## The invariant + +> A synthesized input event whose ack we await must reach the content +> renderer — and when it does not, we must stop waiting. + +## Why it is worth a module and a lint + +Four deadlocks shipped between 2026-04 and 2026-09, all the same failure: + +| Date | Commit | Trigger | +|---|---|---| +| 2026-06-04 | `9270618` | `x == width` / `y == height` — the far edges | +| 2026-07-18 | `541ffca` | trajectory points, which bypassed the endpoint's guard (#225, #677) | +| 2026-07-24 | `16e5a13` | a zero-displacement move | +| 2026-09-03 | `014cc65` | `y == 0` — the near edge (#751, #752) | + +Each was fixed by adding one more coordinate guard at one more call site. That +does not converge, for two reasons. + +**The trigger set is not enumerable.** Whether relative `y == 0` reaches the +renderer is decided by `Math.round(boundingBox.top) < boundingBox.top` — a +rounding accident in the fractional height of browser chrome, which varies with +the *spoofed OS*: windows `51.4` → `51` deadlocks, macos `53.1` → `53` +deadlocks, linux `56.5` → `57` is fine. No review catches that, and no +hand-written list of coordinates contains it. + +**Every miss costs the whole process.** `activateAndRun()` +(`TargetRegistry.js`) serializes input on a promise chain shared by every tab in +the process. It swallows errors to keep the chain running, but it cannot swallow +a callback that never returns. One unbounded `await` for an ack that will never +arrive wedges every later input event, in every tab, permanently — at 0% CPU, +with nothing in flight and no diagnostic. + +`#677` is why review is not enough: restoring the humanize trajectory meant +writing a bounds check, and the one written was a copy of the pre-`#225` form, +reintroducing a fixed deadlock one day before it was re-fixed. + +## How it is enforced + +**One chokepoint.** `MouseDispatch` owns the relative→absolute conversion (with +the boundary snap), the in-viewport predicate, and the ack wait. Callers pass +relative coordinates and never see a bounding box. + +**Bounded waits.** `sendAcked()` waits at most `kAckDeadlineMs` (5s) and then +drops the event with a warning naming the type, coordinate and browser rect. The +deadline is sized above the slowest *legitimate* ack, not near the typical one: +acks are p99 1ms on an idle page, but they are delivered from the content main +thread and inherit any block on it — a 3s synchronous script delayed one by +2849ms. `sendTrajectoryAcked()` abandons the rest of a curve after the first +undelivered point, so ~110 bounded waits cannot add up to an unbounded slot. +`activateAndRun()` carries a 30s backstop for the other unbounded waits +reachable from the same slot (`apz-repaints-flushed`, `TabSwitchDone`, the drag +path's waits), none of which has failed yet. + +**The static check.** `scripts/check-input-dispatch.py`, wired into +`.github/workflows/lint.yml`. Two exemptions, both content-process: +`PageAgent.js` (drag events, already content-relative, no ack) and +`FrameTree.js` (the ack *producer*). + +**Boundary coverage.** `tests/patches/mouse-boundary-sweep.py` sweeps the whole +viewport ring across every spoofed OS with humanize on and off, asserting each +point is acked *and observed by the page*. Hand-picked coordinate lists are what +let each of the four bugs through: `humanize-edge-deadlock.py` probed only the +far edges, and `humanize-mouse-trajectory.py` pins `os="linux"` — the one +fingerprint immune to `#751`. `tests/patches/input-ack-backstop.py` covers the +bounded wait itself. diff --git a/patches/font-hijacker.patch b/patches/font-hijacker.patch index b0a654b..56f23c0 100644 --- a/patches/font-hijacker.patch +++ b/patches/font-hijacker.patch @@ -1,5 +1,42 @@ +Camoufox: restrict font family resolution to the spoofed "fonts" allowlist. + +The allowlist is applied when a family is looked up, NOT by handing the list to +Firefox's `font.system.whitelist` pref (which is what this patch used to do). +That pref drives gfxPlatformFontList::ApplyWhitelist(), which physically +removes every non-listed family from the process-wide font list -- and, when +the shared font list is enabled (the default on all three platforms), from the +read-only list the parent process builds and shares with every content +process. + +The parent process is also the process that paints the browser chrome, so +pruning the list there strips the UI of the fonts it needs. On Windows that +leaves the titlebar buttons drawing tofu boxes instead of their Segoe Fluent +Icons / Segoe MDL2 Assets glyphs (U+E921 / U+E922 / U+E8BB) and the toolbar +falling back to serif: the icon fonts are not in any of the generated masks, +and "Segoe UI" only survives when the spoofed OS is Windows. Linux hosts were +insulated by the bundled FONTCONFIG_FILE, which is why this only shows up on +Windows and macOS builds. + +Filtering at lookup time instead keys off FontVisibilityProvider::IsChrome(), +so chrome documents (browser UI, devtools, about: pages) keep the host's real +font list while web content still only ever resolves families from the mask. +The three content-reachable paths are covered: + + - FindAndAddFamiliesLocked -- font-family / CSS generic resolution, which is + what document.fonts.check() and text-metric + probing go through. + - GlobalFontFallback -- per-character system fallback; also keeps the + cmap path forced, as the whitelist did, so the + platform fallback APIs can't pick a family + behind our back. + - gfxUserFontSet local() -- upstream already refuses local() sources while + a whitelist is active; keep that behaviour for + content under the mask, so a page can't probe + for hidden families via src: local(). + +Bug: daijro/camoufox#695 + diff --git a/gfx/thebes/gfxPlatformFontList.cpp b/gfx/thebes/gfxPlatformFontList.cpp -index 6bdcd2a57c..41d46bf26c 100644 --- a/gfx/thebes/gfxPlatformFontList.cpp +++ b/gfx/thebes/gfxPlatformFontList.cpp @@ -13,6 +13,7 @@ @@ -10,28 +47,131 @@ index 6bdcd2a57c..41d46bf26c 100644 #include "FontVisibilityProvider.h" -@@ -313,6 +314,16 @@ gfxPlatformFontList::gfxPlatformFontList(bool aNeedFullnamePostscriptNames) +@@ -890,6 +891,29 @@ + ToLowerCase(aKeyName); + } - mFontPrefs = MakeUnique(); - -+ // Hijack the kFontSystemWhitelistPref pref -+ if (std::vector fontValues = MaskConfig::GetStringList("fonts"); -+ !fontValues.empty()) { -+ std::string fontValuesJoined = -+ std::accumulate(fontValues.begin(), fontValues.end(), std::string(), -+ [](const std::string& acc, const std::string& s) { -+ return acc.empty() ? s : acc + "," + s; -+ }); -+ Preferences::SetCString(kFontSystemWhitelistPref, fontValuesJoined.data()); ++/* static */ ++bool gfxPlatformFontList::MaskedFontListAppliesTo( ++ FontVisibilityProvider* aFontVisibilityProvider) { ++ if (!MaskConfig::HasFontAllowlist()) { ++ return false; + } - gfxFontUtils::GetPrefsFontList(kFontSystemWhitelistPref, mEnabledFontsList); - mFontFamilyWhitelistActive = !mEnabledFontsList.IsEmpty(); ++ // Chrome documents are not inspectable by the page and they need the host's ++ // real UI fonts -- on Windows the titlebar buttons are Segoe Fluent Icons ++ // glyphs and the toolbar is Segoe UI -- so they are exempt. A null provider ++ // is an internal lookup with no document behind it, which upstream likewise ++ // treats as fully privileged. ++ return aFontVisibilityProvider && !aFontVisibilityProvider->IsChrome(); ++} ++ ++/* static */ ++bool gfxPlatformFontList::MaskedFontListBlocks( ++ FontVisibilityProvider* aFontVisibilityProvider, ++ const nsACString& aLowercaseFamily) { ++ return MaskedFontListAppliesTo(aFontVisibilityProvider) && ++ !MaskConfig::IsFontAllowed(std::string_view( ++ aLowercaseFamily.BeginReading(), aLowercaseFamily.Length())); ++} ++ + // Used if a stylo thread wants to trigger InitOtherFamilyNames in the main + // process: we can't do IPC from the stylo thread so we post this to the main + // thread instead. +@@ -1418,7 +1442,11 @@ + uint32_t aNextCh, Script aRunScript, FontPresentation aPresentation, + const gfxFontStyle* aMatchStyle, uint32_t& aCmapCount, + FontFamily& aMatchedFamily) { +- bool useCmaps = IsFontFamilyWhitelistActive() || ++ // Camoufox: under the spoofed font list, force the cmap path for the same ++ // reason a whitelist does -- the platform fallback APIs choose a family for ++ // us, which would bypass the filtering in the loops below. ++ const bool maskedFontList = MaskedFontListAppliesTo(aFontVisibilityProvider); ++ bool useCmaps = IsFontFamilyWhitelistActive() || maskedFontList || + gfxPlatform::GetPlatform()->UseCmapsDuringSystemFallback(); + FontVisibility level = aFontVisibilityProvider + ? aFontVisibilityProvider->GetFontVisibility() +@@ -1468,6 +1496,11 @@ + if (!IsVisibleToCSS(family, level)) { + continue; + } ++ if (maskedFontList && ++ MaskedFontListBlocks(aFontVisibilityProvider, ++ family.Key().AsString(SharedFontList()))) { ++ continue; ++ } + if (!family.IsFullyInitialized() && + StaticPrefs::gfx_font_rendering_fallback_async() && + !XRE_IsParentProcess()) { +@@ -1494,6 +1527,13 @@ + if (!IsVisibleToCSS(*family, level)) { + continue; + } ++ if (maskedFontList) { ++ nsAutoCString familyKey; ++ GenerateFontListKey(family->Name(), familyKey); ++ if (MaskedFontListBlocks(aFontVisibilityProvider, familyKey)) { ++ continue; ++ } ++ } + // evaluate all fonts in this family for a match + family->FindFontForChar(&data); + if (data.mMatchDistance == 0.0) { +@@ -1754,6 +1794,11 @@ + aFontVisibilityProvider ? aFontVisibilityProvider->GetFontVisibility() + : FontVisibility::User; ++ // Camoufox: content only ever resolves families from the spoofed font list. ++ if (MaskedFontListBlocks(aFontVisibilityProvider, key)) { ++ return false; ++ } ++ + // If this font lookup is the result of resolving a CSS generic (not a direct + // font-family request by the page), and RFP settings allow generics to be + // unrestricted, bump the effective visibility level applied here so as to +diff --git a/gfx/thebes/gfxPlatformFontList.h b/gfx/thebes/gfxPlatformFontList.h +--- a/gfx/thebes/gfxPlatformFontList.h ++++ b/gfx/thebes/gfxPlatformFontList.h +@@ -652,6 +652,19 @@ + return mFontFamilyWhitelistActive; + }; + ++ // Camoufox: the spoofed "fonts" allowlist is applied when a family is looked ++ // up rather than by pruning the process-wide (and cross-process shared) font ++ // list, so that the browser's own chrome UI keeps the host's real UI fonts. ++ // See daijro/camoufox#695. ++ // ++ // MaskedFontListAppliesTo: is aFontVisibilityProvider subject to the mask? ++ // MaskedFontListBlocks: ...and is aLowercaseFamily absent from it? ++ static bool MaskedFontListAppliesTo( ++ FontVisibilityProvider* aFontVisibilityProvider); ++ static bool MaskedFontListBlocks( ++ FontVisibilityProvider* aFontVisibilityProvider, ++ const nsACString& aLowercaseFamily); ++ + static void FontWhitelistPrefChanged(const char* aPref, void* aClosure); + + bool AddWithLegacyFamilyName(const nsACString& aLegacyName, +diff --git a/gfx/thebes/gfxUserFontSet.cpp b/gfx/thebes/gfxUserFontSet.cpp +--- a/gfx/thebes/gfxUserFontSet.cpp ++++ b/gfx/thebes/gfxUserFontSet.cpp +@@ -461,8 +461,12 @@ + gfxPlatformFontList* pfl = gfxPlatformFontList::PlatformFontList(); + pfl->AddUserFontSet(fontSet); + // Don't look up local fonts if the font whitelist is being used. ++ // Camoufox: nor for content under the spoofed font list -- src: local() ++ // would otherwise let a page probe for families the mask hides. + gfxFontEntry* fe = nullptr; +- if (!pfl->IsFontFamilyWhitelistActive()) { ++ if (!pfl->IsFontFamilyWhitelistActive() && ++ !gfxPlatformFontList::MaskedFontListAppliesTo( ++ fontSet->GetFontVisibilityProvider())) { + fe = gfxPlatform::GetPlatform()->LookupLocalFont( + fontSet->GetFontVisibilityProvider(), currSrc.mLocalName, Weight(), + Stretch(), SlantStyle()); diff --git a/gfx/thebes/moz.build b/gfx/thebes/moz.build -index 2bb893d84f..0088f6cab7 100644 --- a/gfx/thebes/moz.build +++ b/gfx/thebes/moz.build -@@ -299,3 +299,6 @@ DEFINES["GRAPHITE2_STATIC"] = True +@@ -299,3 +299,6 @@ COMPILE_FLAGS["WARNINGS_CXXFLAGS"] += ["-Werror=switch"] include("/tools/fuzzing/libfuzzer-config.mozbuild") @@ -39,10 +179,9 @@ index 2bb893d84f..0088f6cab7 100644 +# DOM Mask +LOCAL_INCLUDES += ["/camoucfg"] diff --git a/layout/style/FontFace.cpp b/layout/style/FontFace.cpp -index 56e579eb33..5a679f75ba 100644 --- a/layout/style/FontFace.cpp +++ b/layout/style/FontFace.cpp -@@ -239,7 +239,16 @@ void FontFace::SetSizeAdjust(const nsACString& aValue, ErrorResult& aRv) { +@@ -239,7 +239,16 @@ mImpl->SetSizeAdjust(aValue, aRv); } @@ -60,7 +199,7 @@ index 56e579eb33..5a679f75ba 100644 Promise* FontFace::Load(ErrorResult& aRv) { EnsurePromise(); -@@ -249,7 +258,17 @@ Promise* FontFace::Load(ErrorResult& aRv) { +@@ -249,7 +258,17 @@ return nullptr; } @@ -80,10 +219,9 @@ index 56e579eb33..5a679f75ba 100644 return mLoaded; } diff --git a/layout/style/FontFaceImpl.cpp b/layout/style/FontFaceImpl.cpp -index f13782ae6d..abb8d83fb3 100644 --- a/layout/style/FontFaceImpl.cpp +++ b/layout/style/FontFaceImpl.cpp -@@ -354,21 +354,15 @@ void FontFaceImpl::DoLoad() { +@@ -354,21 +354,15 @@ void FontFaceImpl::SetStatus(FontFaceLoadStatus aStatus) { gfxFontUtils::AssertSafeThreadOrServoFontMetricsLocked(); @@ -112,7 +250,6 @@ index f13782ae6d..abb8d83fb3 100644 mFontFaceSet->OnFontFaceStatusChanged(this); } diff --git a/layout/style/FontFaceImpl.h b/layout/style/FontFaceImpl.h -index c94388212e..2826682e7b 100644 --- a/layout/style/FontFaceImpl.h +++ b/layout/style/FontFaceImpl.h @@ -8,6 +8,7 @@ @@ -123,32 +260,25 @@ index c94388212e..2826682e7b 100644 #include "nsTHashSet.h" class gfxFontFaceBufferSource; -@@ -27,6 +28,20 @@ class UTF8StringOrArrayBufferOrArrayBufferView; +@@ -27,6 +28,14 @@ namespace mozilla::dom { +// Helper function to check if a font is in the allowed list +inline bool IsFontAllowed(const nsACString& aFontName) { -+ if (std::vector maskValues = -+ MaskConfig::GetStringListLower("fonts"); -+ !maskValues.empty()) { -+ std::string fontName(aFontName.BeginReading(), aFontName.EndReading()); -+ std::transform(fontName.begin(), fontName.end(), fontName.begin(), -+ ::tolower); -+ return std::find(maskValues.begin(), maskValues.end(), fontName) != -+ maskValues.end(); -+ } -+ return true; ++ std::string fontName(aFontName.BeginReading(), aFontName.Length()); ++ std::transform(fontName.begin(), fontName.end(), fontName.begin(), ++ [](unsigned char c) { return std::tolower(c); }); ++ return MaskConfig::IsFontAllowed(fontName); +} + class FontFaceImpl final { NS_INLINE_DECL_THREADSAFE_REFCOUNTING(FontFaceImpl) diff --git a/layout/style/moz.build b/layout/style/moz.build -index b3886e491f..9236483cec 100644 --- a/layout/style/moz.build +++ b/layout/style/moz.build -@@ -370,3 +370,6 @@ if CONFIG["COMPILE_ENVIRONMENT"]: +@@ -370,3 +370,6 @@ "ServoStyleConsts.h", inputs=["/servo/ports/geckolib", "/servo/components/style"], ) diff --git a/patches/font-system-fonts-css2.patch b/patches/font-system-fonts-css2.patch index b505ddb..7d9dee7 100644 --- a/patches/font-system-fonts-css2.patch +++ b/patches/font-system-fonts-css2.patch @@ -1,8 +1,8 @@ Camoufox: spoof CSS2 system-font keyword resolution to match navigator.platform. -Sibling to font-system-ui.patch. That patch covers the `system-ui` generic -via gfxPlatformFontList::GetSystemUIFontFamilies(). It does NOT cover the -older CSS2 system-font shorthand keywords: +Sibling to system-ui-font-spoofing.patch. That patch covers the `system-ui` +generic via gfxPlatformFontList::GetSystemUIFontFamilies(). It does NOT cover +the older CSS2 system-font shorthand keywords: font: caption | icon | menu | message-box | small-caption | status-bar @@ -18,19 +18,17 @@ keywords: // GeckoFonts["Sans"] === GeckoFonts["sans-serif"] === "Linux" // -> emits "Sans:Linux" / "sans-serif:Linux" -Firefox routes CSS system-font resolution through TWO different paths -depending on whether RFP's FontVisibilityRestrictGenerics target is on: +Firefox routes CSS system-font resolution through nsLayoutUtils:: +ComputeSystemFont(), which either asks nsLayoutUtils' own +GetSpoofedSystemFontForRFP() (when RFP's FontVisibilityRestrictGenerics target +is on) or the platform LookAndFeel (otherwise). FontVisibilityRestrictGenerics +is not in Firefox's default-enabled set (RFPTargetsDefault.inc) and Camoufox +never turns it on, so upstream always took the LookAndFeel branch and the real +host system font leaked through getComputedStyle().fontFamily. - 1. RFP off (or target off): nsLookAndFeel::PerThemeData::GetFont() - returns the GTK system font (e.g. "Cantarell"). - 2. RFP on (Camoufox default): nsLayoutUtils::GetSpoofedSystemFontForRFP() - returns "sans-serif" (Linux build) or "-apple-system" (Mac build) etc. - -Camoufox runs RFP on by default, so CreepJS sees path 2 — which on the -Linux build hardcodes "sans-serif" and entirely bypasses the -LookAndFeel::GetFont call site. - -This patch hooks BOTH paths to honor navigator.platform: +This patch makes ComputeSystemFont() take the spoof branch whenever +navigator.platform is spoofed, and teaches GetSpoofedSystemFontForRFP() to +answer for the spoofed OS: - "-apple-system" when navigator.platform starts with "Mac" (matches CreepJS GeckoFonts entry "-apple-system" -> "Mac") @@ -38,90 +36,29 @@ This patch hooks BOTH paths to honor navigator.platform: (matches CreepJS GeckoFonts entry "Segoe UI" -> "Windows") - (falls through) otherwise (Linux / iPhone / unspoofed) -For path 1 we patch widget/gtk/nsLookAndFeel.cpp's PerThemeData::GetFont. -For path 2 we patch layout/base/nsLayoutUtils.cpp's -GetSpoofedSystemFontForRFP (file-static) helper. Both files need -LOCAL_INCLUDES += ["/camoucfg"] in their respective moz.build to expose -MaskConfig.hpp. +Chrome documents are excluded from the spoof branch and keep the host's real +system font. They are not reachable by the page, and handing the browser's own +UI a family name from another OS ("Segoe UI" on Linux, "-apple-system" on +Windows) leaves the toolbar, menus and titlebar falling back to the default +serif font -- see daijro/camoufox#695. -Both hooks classify navigator.platform once via a function-static and -keep the result for the lifetime of the process. CreepJS calls the CSS -resolver six times in tight succession (one per CSS2 keyword) and uses -probe duration as a probabilistic headless signal, so we don't want to -re-parse CAMOU_CONFIG on every call. The config is set at process start -and never changes, so a single classification per process is safe. +Because ComputeSystemFont() is the single entry point and now covers every +content document unconditionally, the LookAndFeel-level hooks this patch used +to carry (widget/gtk/nsLookAndFeel.cpp::PerThemeData::GetFont and +gfxMacPlatformFontList::LookupSystemFont) can never fire for content: they are +reached only when the spoof branch is *not* taken, which is exactly when +navigator.platform is unspoofed (where they were no-ops) or when the document +is chrome (where they broke the UI). They are dropped here. -Bug: daijro/camoufox#598 (sibling of font-system-ui.patch) +The classification is cached in a function-static. CreepJS calls the CSS +resolver six times in tight succession (one per CSS2 keyword) and uses probe +duration as a probabilistic headless signal, so we don't want to re-parse +CAMOU_CONFIG on every call. The config is set at process start and never +changes, so a single classification per process is safe. ---- a/widget/gtk/moz.build -+++ b/widget/gtk/moz.build -@@ -155,6 +155,7 @@ - FINAL_LIBRARY = "xul" +Bug: daijro/camoufox#598, daijro/camoufox#695 - LOCAL_INCLUDES += [ -+ "/camoucfg", - "/layout/base", - "/layout/forms", - "/layout/generic", ---- a/widget/gtk/nsLookAndFeel.cpp -+++ b/widget/gtk/nsLookAndFeel.cpp -@@ -34,6 +34,7 @@ - #include "mozilla/glean/WidgetGtkMetrics.h" - #include "mozilla/ScopeExit.h" - #include "mozilla/WidgetUtilsGtk.h" -+#include "MaskConfig.hpp" - #include "ScreenHelperGTK.h" - #include "ScrollbarDrawing.h" - -@@ -1302,6 +1303,48 @@ - bool nsLookAndFeel::PerThemeData::GetFont(FontID aID, nsString& aFontName, - gfxFontStyle& aFontStyle, - float aTextScaleFactor) const { -+ // Camoufox: spoof the CSS2 system-font keyword family resolution -+ // (caption / icon / menu / message-box / small-caption / status-bar) -+ // based on navigator.platform so we don't leak the host's GTK system -+ // font through getComputedStyle().fontFamily. Sibling to -+ // font-system-ui.patch which handles the `system-ui` generic. -+ // -+ // The platform classification is cached in a function-static. CreepJS's -+ // headless detector calls getComputedStyle().fontFamily after setting -+ // `font: caption !important` six times in tight succession, and uses -+ // probe duration as a probabilistic headless signal. navigator.platform -+ // is set from CAMOU_CONFIG at process start and never changes, so a -+ // single lookup per process is safe and removes the per-call overhead. -+ enum class SpoofedOS { Other, Mac, Win }; -+ static const SpoofedOS sSpoofedOS = []() { -+ auto platform = MaskConfig::GetString("navigator.platform"); -+ if (!platform) return SpoofedOS::Other; -+ const std::string& p = platform.value(); -+ auto starts_with = [&](const char* prefix) { -+ size_t n = std::strlen(prefix); -+ return p.size() >= n && -+ std::equal(prefix, prefix + n, p.begin(), -+ [](char a, char b) { -+ return std::tolower(static_cast(a)) == -+ std::tolower(static_cast(b)); -+ }); -+ }; -+ if (starts_with("mac")) return SpoofedOS::Mac; -+ if (starts_with("win")) return SpoofedOS::Win; -+ return SpoofedOS::Other; -+ }(); -+ if (sSpoofedOS == SpoofedOS::Mac) { -+ aFontName.AssignLiteral(u"-apple-system"); -+ aFontStyle = mDefaultFontStyle; -+ return true; -+ } -+ if (sSpoofedOS == SpoofedOS::Win) { -+ aFontName.AssignLiteral(u"Segoe UI"); -+ aFontStyle = mDefaultFontStyle; -+ return true; -+ } -+ // Linux / iPhone / unknown: fall through to upstream behavior. -+ - switch (aID) { - case FontID::Menu: // css2 - case FontID::MozPullDownMenu: // css3 +diff --git a/layout/base/moz.build b/layout/base/moz.build --- a/layout/base/moz.build +++ b/layout/base/moz.build @@ -146,6 +146,7 @@ @@ -132,38 +69,40 @@ Bug: daijro/camoufox#598 (sibling of font-system-ui.patch) "/docshell/base", "/dom/base", "/dom/html", +diff --git a/layout/base/nsLayoutUtils.cpp b/layout/base/nsLayoutUtils.cpp --- a/layout/base/nsLayoutUtils.cpp +++ b/layout/base/nsLayoutUtils.cpp -@@ -6,6 +6,7 @@ - +@@ -4,6 +4,7 @@ + #include "nsLayoutUtils.h" - + +#include "MaskConfig.hpp" #include #include - -@@ -9623,6 +9654,54 @@ - + +@@ -9718,6 +9719,55 @@ + static void GetSpoofedSystemFontForRFP(LookAndFeel::FontID aFontID, gfxFontStyle& aStyle, nsAString& aName) { + // Camoufox: when navigator.platform is spoofed, return the family-name + // string the spoofed OS would. Firefox's CSS resolver routes the CSS2 + // system-font keywords (caption / icon / menu / message-box / -+ // small-caption / status-bar) through this function whenever RFP's -+ // FontVisibilityRestrictGenerics target is on (default for Camoufox), -+ // bypassing nsLookAndFeel::PerThemeData::GetFont entirely. CreepJS's -+ // headless detector reads `getComputedStyle().fontFamily` after setting -+ // `font: caption !important` and emits ":Linux" / ":Mac" -+ // / ":Windows" attributions. Without this hook the Linux build -+ // emits `sans-serif` (which fontconfig resolves to "Sans") for every -+ // spoofed OS, leaking Linux. Sibling to the nsLookAndFeel::GetFont hook -+ // in widget/gtk/nsLookAndFeel.cpp. ++ // small-caption / status-bar) through this function. CreepJS's headless ++ // detector reads `getComputedStyle().fontFamily` after setting ++ // `font: caption !important` and emits ":Linux" / ":Mac" / ++ // ":Windows" attributions. Without this hook the Linux build emits ++ // `sans-serif` (which fontconfig resolves to "Sans") for every spoofed OS, ++ // leaking Linux. Sibling to the system-ui hook in ++ // gfxPlatformFontList::GetSystemUIFontFamilies(). ++ // ++ // ComputeSystemFont() below only routes *content* documents here, so this ++ // never changes what the browser's own chrome UI resolves to. + // + // The platform classification is cached in a function-static because -+ // CreepJS calls this six times in tight succession (one per CSS2 -+ // keyword) and uses probe duration as a probabilistic headless signal. -+ // navigator.platform is set from CAMOU_CONFIG at process start and -+ // never changes, so a single lookup per process is safe. ++ // CreepJS calls this six times in tight succession (one per CSS2 keyword) ++ // and uses probe duration as a probabilistic headless signal. ++ // navigator.platform is set from CAMOU_CONFIG at process start and never ++ // changes, so a single lookup per process is safe. + enum class SpoofedOS { Other, Mac, Win }; + static const SpoofedOS sSpoofedOS = []() { + auto platform = MaskConfig::GetString("navigator.platform"); @@ -192,101 +131,33 @@ Bug: daijro/camoufox#598 (sibling of font-system-ui.patch) + aStyle.size = 12; + return; + } -+ // Linux / iPhone / unknown: fall through to upstream behavior. ++ // Linux / iPhone / unspoofed: fall through to upstream behavior. + #if defined(XP_MACOSX) || defined(MOZ_WIDGET_UIKIT) aName = u"-apple-system"_ns; // Values taken from a macOS 10.15 system. -@@ -9729,7 +9729,17 @@ void nsLayoutUtils::ComputeSystemFont(nsFont* aSystemFont, +@@ -9772,8 +9822,22 @@ const Document* aDocument) { gfxFontStyle fontStyle; nsAutoString systemFontName; - if (aDocument->ShouldResistFingerprinting( +- RFPTarget::FontVisibilityRestrictGenerics)) { + // Camoufox: take the spoof path whenever navigator.platform is spoofed, not + // only when the FontVisibilityRestrictGenerics RFP target is active. That + // target is NOT in Firefox's default-enabled set (RFPTargetsDefault.inc) and + // Camoufox never turns it on, so the original guard left the spoof dead: the -+ // real GTK system font ("sans-serif" on Linux) leaked through ++ // real system font ("sans-serif" on Linux) leaked through + // getComputedStyle().fontFamily for the CSS2 system-font keywords, exposing -+ // the host OS (CreepJS "platform hints: sans-serif:Linux"). When no platform -+ // is spoofed, GetSpoofedSystemFontForRFP falls through to the native value, -+ // so this is a no-op for unspoofed contexts. -+ if (MaskConfig::GetString("navigator.platform").has_value() || -+ aDocument->ShouldResistFingerprinting( - RFPTarget::FontVisibilityRestrictGenerics)) { ++ // the host OS (CreepJS "platform hints: sans-serif:Linux"). ++ // ++ // Chrome documents are excluded. They are not reachable by the page, and ++ // handing the browser's own UI a family name from another OS ("Segoe UI" on ++ // Linux, "-apple-system" on Windows) leaves the toolbar and menus falling ++ // back to the default serif font -- see daijro/camoufox#695. ++ if (aDocument && !aDocument->IsInChromeDocShell() && ++ (MaskConfig::GetString("navigator.platform").has_value() || ++ aDocument->ShouldResistFingerprinting( ++ RFPTarget::FontVisibilityRestrictGenerics))) { GetSpoofedSystemFontForRFP(aFontID, fontStyle, systemFontName); } else if (!LookAndFeel::GetFont(aFontID, systemFontName, fontStyle)) { -diff --git a/gfx/thebes/gfxMacPlatformFontList.mm b/gfx/thebes/gfxMacPlatformFontList.mm ---- a/gfx/thebes/gfxMacPlatformFontList.mm -+++ b/gfx/thebes/gfxMacPlatformFontList.mm -@@ -17,6 +17,7 @@ - #include "SharedFontList-impl.h" - - #include "harfbuzz/hb.h" -+#include "MaskConfig.hpp" - - #include "AppleUtils.h" - #include "MainThreadUtils.h" -@@ -394,7 +395,48 @@ void gfxMacPlatformFontList::LookupSystemFont(LookAndFeel::FontID aSystemFontID, - } - NS_ASSERTION(font, "system font not set"); - -- aSystemFontName.AssignASCII("-apple-system"); -+ // Camoufox: spoof the CSS2 system-font keyword family resolution -+ // (caption / icon / menu / message-box / small-caption / status-bar) -+ // based on navigator.platform. This is the cocoa sibling of the GTK -+ // hook in widget/gtk/nsLookAndFeel.cpp::PerThemeData::GetFont. -+ // -+ // Without this hook the Mac build always emits "-apple-system" here, -+ // which CreepJS's GeckoFonts map attributes to "Mac" — leaking the -+ // host OS through `getComputedStyle().fontFamily` even when -+ // navigator.platform is spoofed to Win32. -+ // -+ // The Camoufox-runs-RFP-by-default premise that the nsLayoutUtils.cpp -+ // hunk relied on is no longer true: privacy.resistFingerprinting is -+ // off and FontVisibilityRestrictGenerics is not in the default RFP -+ // target set, so ComputeSystemFont takes the LookAndFeel::GetFont -+ // branch on every call. On Mac that lands here. -+ // -+ // The platform classification is cached in a function-static for the -+ // same reason as the sibling hooks: CreepJS uses probe duration as a -+ // probabilistic headless signal. -+ enum class SpoofedOS { Other, Mac, Win }; -+ static const SpoofedOS sSpoofedOS = []() { -+ auto platform = MaskConfig::GetString("navigator.platform"); -+ if (!platform) return SpoofedOS::Other; -+ const std::string& p = platform.value(); -+ auto starts_with = [&](const char* prefix) { -+ size_t n = std::strlen(prefix); -+ return p.size() >= n && -+ std::equal(prefix, prefix + n, p.begin(), -+ [](char a, char b) { -+ return std::tolower(static_cast(a)) == -+ std::tolower(static_cast(b)); -+ }); -+ }; -+ if (starts_with("mac")) return SpoofedOS::Mac; -+ if (starts_with("win")) return SpoofedOS::Win; -+ return SpoofedOS::Other; -+ }(); -+ if (sSpoofedOS == SpoofedOS::Win) { -+ aSystemFontName.AssignASCII("Segoe UI"); -+ } else { -+ aSystemFontName.AssignASCII("-apple-system"); -+ } - - NSFontSymbolicTraits traits = font.fontDescriptor.symbolicTraits; - aFontStyle.style = (traits & NSFontItalicTrait) ? FontSlantStyle::ITALIC - ---- a/gfx/thebes/moz.build -+++ b/gfx/thebes/moz.build -@@ -302,3 +302,9 @@ - - # DOM Mask - LOCAL_INCLUDES += ["/camoucfg"] -+ -+# Objective-C++ compilation in this directory keeps exceptions enabled for -+# ObjC @try/@catch even when C++ has -fno-exceptions, which makes -+# nlohmann/json (transitively included via MaskConfig.hpp) emit `throw` -+# expressions that don't compile. Force the JSON_THROW=std::abort path. -+DEFINES["JSON_NOEXCEPTION"] = True + return; diff --git a/patches/force-default-pointer.patch b/patches/force-default-pointer.patch index fe24a2e..b00173f 100644 --- a/patches/force-default-pointer.patch +++ b/patches/force-default-pointer.patch @@ -1,31 +1,57 @@ diff --git a/layout/style/nsMediaFeatures.cpp b/layout/style/nsMediaFeatures.cpp -index cc86d1abf6..bfc4d0f1d8 100644 --- a/layout/style/nsMediaFeatures.cpp +++ b/layout/style/nsMediaFeatures.cpp -@@ -372,24 +372,10 @@ static PointerCapabilities GetPointerCapabilities(const Document* aDocument, +@@ -408,34 +433,41 @@ static PointerCapabilities GetPointerCapabilities(const Document* aDocument, + // that we don't need to care about ResistFingerprinting. + if (bc->TouchEventsOverride() == dom::TouchEventsOverride::Enabled) { + return PointerCapabilities::Coarse; + } + } // The default value for Desktop is mouse-type pointer, and for Android // a coarse pointer. - const PointerCapabilities kDefaultCapabilities = #ifdef ANDROID - PointerCapabilities::Coarse; --#else -- PointerCapabilities::Fine | PointerCapabilities::Hover; -+ return PointerCapabilities::Coarse; - #endif ++ return PointerCapabilities::Coarse; + #else ++ // Report the desktop default rather than whatever the host toolkit ++ // advertises, so the capability set never varies with the machine the ++ // browser happens to be running on. ++ PointerCapabilities capabilities = + PointerCapabilities::Fine | PointerCapabilities::Hover; +-#endif - if (aDocument->ShouldResistFingerprinting( - RFPTarget::CSSPointerCapabilities)) { - return kDefaultCapabilities; - } -- + - int32_t intValue; - nsresult rv = LookAndFeel::GetInt(aID, &intValue); - if (NS_FAILED(rv)) { - return kDefaultCapabilities; -- } -- ++ // A touchscreen laptop still drives a fine, hovering *primary* pointer -- ++ // its trackpad -- and the digitizer only ever joins the `any-pointer` set. ++ // Keeping that split is the whole point: a spoofed maxTouchPoints paired ++ // with `(pointer: coarse)` would claim a phone, while the accompanying ++ // desktop UA said otherwise. ++ if (aID == LookAndFeel::IntID::AllPointerCapabilities) { ++ if (auto maxTouchPoints = ++ MaskConfig::GetUint32("navigator.maxTouchPoints")) { ++ if (maxTouchPoints.value() > 0) { ++ capabilities |= PointerCapabilities::Coarse; ++ } ++ } + } + - return static_cast(intValue); -+ return PointerCapabilities::Fine | PointerCapabilities::Hover; ++ return capabilities; ++#endif } PointerCapabilities Gecko_MediaFeatures_PrimaryPointerCapabilities( + const Document* aDocument) { + return GetPointerCapabilities(aDocument, + LookAndFeel::IntID::PrimaryPointerCapabilities); + } + diff --git a/patches/ghostery/Disable-Onboarding-Messages.patch.bak b/patches/ghostery/Disable-Onboarding-Messages.patch.bak deleted file mode 100644 index f9ffefb..0000000 --- a/patches/ghostery/Disable-Onboarding-Messages.patch.bak +++ /dev/null @@ -1,23 +0,0 @@ -From: Jenkins -Date: Fri, 3 Mar 2023 09:03:01 +0100 -Subject: Disable Onboarding Messages - ---- - browser/components/asrouter/modules/OnboardingMessageProvider.sys.mjs | 3 +-- - 1 file changed, 1 insertion(+), 2 deletions(-) - -diff --git a/browser/components/asrouter/modules/OnboardingMessageProvider.sys.mjs b/browser/components/asrouter/modules/OnboardingMessageProvider.sys.mjs -index ceded6b755..90aa3abe36 100644 ---- a/browser/components/asrouter/modules/OnboardingMessageProvider.sys.mjs -+++ b/browser/components/asrouter/modules/OnboardingMessageProvider.sys.mjs -@@ -1213,11 +1213,9 @@ const BASE_MESSAGES = () => [ - ]; - - // Eventually, move Feature Callout messages to their own provider --const ONBOARDING_MESSAGES = () => -- BASE_MESSAGES().concat(FeatureCalloutMessages.getMessages()); -+const ONBOARDING_MESSAGES = () => ([]); - - export const OnboardingMessageProvider = { - async getExtraAttributes() { - diff --git a/patches/librewolf/urlbarprovider-interventions.patch.bak b/patches/librewolf/urlbarprovider-interventions.patch.bak deleted file mode 100644 index 83676b9..0000000 --- a/patches/librewolf/urlbarprovider-interventions.patch.bak +++ /dev/null @@ -1,13 +0,0 @@ -diff --git a/browser/components/urlbar/UrlbarProviderInterventions.sys.mjs b/browser/components/urlbar/UrlbarProviderInterventions.sys.mjs -index 575205158bb8..e0c76c682a7d 100644 ---- a/browser/components/urlbar/UrlbarProviderInterventions.sys.mjs -+++ b/browser/components/urlbar/UrlbarProviderInterventions.sys.mjs -@@ -454,9 +454,8 @@ export class UrlbarProviderInterventions extends UrlbarProvider { - ]), - }); - for (let [id, phrases] of Object.entries(DOCUMENTS)) { -- queryScorer.addDocument({ id, phrases }); -+// queryScorer.addDocument({ id, phrases }); - } - return queryScorer; - }); diff --git a/patches/media-codec-spoofing.patch b/patches/media-codec-spoofing.patch new file mode 100644 index 0000000..d727661 --- /dev/null +++ b/patches/media-codec-spoofing.patch @@ -0,0 +1,74 @@ +diff --git a/dom/media/mp4/MP4Decoder.cpp b/dom/media/mp4/MP4Decoder.cpp +index abcdef0001..abcdef0002 100644 +--- a/dom/media/mp4/MP4Decoder.cpp ++++ b/dom/media/mp4/MP4Decoder.cpp +@@ -10,6 +10,7 @@ + #include "MediaContainerType.h" + #include "PDMFactory.h" + #include "PlatformDecoderModule.h" ++#include "MaskConfig.hpp" + #include "VPXDecoder.h" + #include "VideoUtils.h" + #include "mozilla/StaticPrefs_media.h" +@@ -133,6 +134,14 @@ bool MP4Decoder::IsSupportedType(const MediaContainerType& aType, + return false; + } + ++ // Camoufox: When media:spoof_codecs is enabled, always report MP4 as ++ // supported. This prevents canPlayType()/isTypeSupported() from leaking ++ // which system codec libraries (FFmpeg, VideoToolbox, GStreamer) are ++ // installed. Actual playback may fail if no decoder is present. ++ if (MaskConfig::GetBool("media:spoof_codecs").value_or(false)) { ++ return true; ++ } ++ + if (!tracks.IsEmpty()) { + // Look for exact match as we know used codecs. + RefPtr platform = new PDMFactory(); +diff --git a/dom/media/mp4/moz.build b/dom/media/mp4/moz.build +index abcdef0003..abcdef0004 100644 +--- a/dom/media/mp4/moz.build ++++ b/dom/media/mp4/moz.build +@@ -32,3 +32,6 @@ CXXFLAGS += [ + + # Add libFuzzer configuration directives + include("/tools/fuzzing/libfuzzer-config.mozbuild") ++ ++# DOM Mask ++LOCAL_INCLUDES += ["/camoucfg"] +diff --git a/dom/media/webm/MatroskaDecoder.cpp b/dom/media/webm/MatroskaDecoder.cpp +index abcdef0005..abcdef0006 100644 +--- a/dom/media/webm/MatroskaDecoder.cpp ++++ b/dom/media/webm/MatroskaDecoder.cpp +@@ -12,6 +12,7 @@ + #include "MediaContainerType.h" + #include "PDMFactory.h" + #include "PlatformDecoderModule.h" ++#include "MaskConfig.hpp" + #include "VideoUtils.h" + #include "mozilla/StaticPrefs_media.h" + #include "nsMimeTypes.h" +@@ -130,6 +131,12 @@ bool MatroskaDecoder::IsSupportedType(const MediaContainerType& aContainerType, + + if (!tracks.IsEmpty()) { + // Look for exact match as we know the codecs used. ++ ++ // Camoufox: bypass system decoder check when media:spoof_codecs is enabled. ++ if (MaskConfig::GetBool("media:spoof_codecs").value_or(false)) { ++ return true; ++ } ++ + RefPtr platform = new PDMFactory(); + for (const auto& track : tracks) { + if (!track || +diff --git a/dom/media/webm/moz.build b/dom/media/webm/moz.build +index abcdef0007..abcdef0008 100644 +--- a/dom/media/webm/moz.build ++++ b/dom/media/webm/moz.build +@@ -29,3 +29,6 @@ FINAL_LIBRARY = "xul" + + # Add libFuzzer configuration directives + include("/tools/fuzzing/libfuzzer-config.mozbuild") ++ ++# DOM Mask ++LOCAL_INCLUDES += ["/camoucfg"] diff --git a/patches/no-search-engines.patch b/patches/no-search-engines.patch index 6f15bb1..4595e3b 100644 --- a/patches/no-search-engines.patch +++ b/patches/no-search-engines.patch @@ -12,28 +12,71 @@ index e5bda9f62c..f8988a4430 100644 queryContext.searchString.length > UrlbarUtils.MAX_TEXT_LENGTH || lazy.UrlUtils.REGEXP_LIKE_PROTOCOL.test(queryContext.searchString) || diff --git a/toolkit/components/search/SearchEngineSelector.sys.mjs b/toolkit/components/search/SearchEngineSelector.sys.mjs -index 85c9d83282..15cfeac054 100644 --- a/toolkit/components/search/SearchEngineSelector.sys.mjs +++ b/toolkit/components/search/SearchEngineSelector.sys.mjs -@@ -331,6 +331,21 @@ export class SearchEngineSelector { +@@ -325,12 +325,65 @@ export class SearchEngineSelector { + * Internal boolean to indicate if this is the first time check or not. + * @returns {Promise} + * An array of objects in the database, or an empty array if none * could be obtained. */ async #getConfiguration(firstTime = true) { + if (true) { ++ // Camoufox ships no search engines. This stub stands in for the Remote ++ // Settings fetch, which is dead anyway: camoufox.cfg sets ++ // services.settings.server to "", so nothing is ever fetched. ++ // ++ // The shape matters. This used to return a search-config **v1** record ++ // (`appliesTo`/`webExtension`, no `recordType`), but the selector is the ++ // Rust SearchEngineSelector, which deserializes v2: ++ // ++ // #[serde(tag = "recordType", rename_all = "camelCase")] ++ // enum JSONSearchConfigurationRecords { ... } ++ // ++ // `recordType` is the enum's tag, so a record without it aborts the whole ++ // document with `missing field \`recordType\``. setSearchConfig() threw on ++ // every launch, #init() died, and the browser ended up with no search ++ // service at all -- which also takes out the urlbar's heuristic result, ++ // so history and autofill never render (daijro/camoufox#737). ++ // ++ // Returning [] is not an option either: getEngineConfiguration() rejects ++ // an empty array with "Failed to get engine data from Remote Settings". ++ // So the configuration has to carry one inert engine. Its search URL ++ // points at the loopback address, so the "no engines" stance holds even ++ // if something does try to submit a search -- the request cannot leave ++ // the machine. settings/distribution/policies.json separately defines a ++ // "None" engine and makes it the default; this simply agrees with it. + return [ + { -+ "appliesTo": [{ -+ "default": "yes", -+ "included": { -+ "everywhere": true -+ }, -+ "webExtension": { -+ "id": "none@mozilla.org" ++ "recordType": "engine", ++ "identifier": "none", ++ "base": { ++ "name": "None", ++ "classification": "unknown", ++ "urls": { ++ "search": { ++ "base": "http://127.0.0.1/", ++ "method": "GET", ++ "searchTermParamName": "q" ++ } + } -+ }], ++ }, ++ "variants": [{ "environment": { "allRegionsAndLocales": true } }] + }, ++ { ++ "recordType": "defaultEngines", ++ "globalDefault": "none", ++ "specificDefaults": [] ++ }, ++ { ++ "recordType": "engineOrders", ++ "orders": [] ++ } + ]; + } let result = []; let failed = false; try { + result = await this.#remoteConfig.get({ + order: "id", + }); diff --git a/patches/patch-dependencies.md b/patches/patch-dependencies.md new file mode 100644 index 0000000..3233058 --- /dev/null +++ b/patches/patch-dependencies.md @@ -0,0 +1,24 @@ +# Patch Dependencies + +Quick reference for which patches depend on shared infrastructure. + +## camoucfg (MaskConfig) + +Most patches read config via `MaskConfig::GetBool()`, `MaskConfig::GetString()`, etc. from `/camoucfg`. Any patch that adds `LOCAL_INCLUDES += ["/camoucfg"]` to a `moz.build` file depends on `config.patch` being applied first (which provides the `camoucfg` directory). + +### Patches using MaskConfig + +| Patch | Config keys | What it does | +|-------|-------------|--------------| +| `media-codec-spoofing.patch` | `media:spoof_codecs` | Bypasses `PDMFactory::Supports()` checks in `MP4Decoder` and `MatroskaDecoder` so `canPlayType()`/`isTypeSupported()` don't leak system codec libraries | +| `navigator-spoofing.patch` | Various `navigator:*` keys | Per-context navigator property spoofing | +| `geolocation-spoofing.patch` | `geo:*` keys | Geolocation coordinate spoofing | +| `locale-spoofing.patch` | `locale:*` keys | Language/locale spoofing | + +## RoverfoxStorageManager + +Per-context patches that use cross-process storage depend on `cross-process-storage.patch`. + +## Playwright + +All patches should be applied after `0-playwright.patch` and `1-leak-fixes.patch`. diff --git a/patches/system-ui-font-spoofing.patch b/patches/system-ui-font-spoofing.patch index 80b432d..657c9d7 100644 --- a/patches/system-ui-font-spoofing.patch +++ b/patches/system-ui-font-spoofing.patch @@ -1,20 +1,38 @@ +Camoufox: spoof the `system-ui` CSS generic to match the spoofed OS. + +Sibling to font-system-fonts-css2.patch, which covers the older CSS2 +system-font shorthand keywords (caption / icon / menu / message-box / +small-caption / status-bar) via nsLayoutUtils::ComputeSystemFont(). + +Chrome documents are excluded. The browser's own UI is not reachable by the +page, and resolving its `system-ui` to a family from another OS ("Helvetica" +or "Segoe UI" on a host that has neither) leaves the toolbar and menus falling +back to the default serif font. + +Bug: daijro/camoufox#695 (chrome exemption), daijro/camoufox#599 (spoof) + diff --git a/gfx/thebes/gfxPlatformFontList.cpp b/gfx/thebes/gfxPlatformFontList.cpp -index d82edfc767..2e05cedcb3 100644 --- a/gfx/thebes/gfxPlatformFontList.cpp +++ b/gfx/thebes/gfxPlatformFontList.cpp -@@ -2246,6 +2246,18 @@ void gfxPlatformFontList::MaybeRemoveCmap(gfxCharacterMap* aCharMap, +@@ -2245,6 +2245,24 @@ static void GetSystemUIFontFamilies( FontVisibilityProvider* aFontVisibilityProvider, [[maybe_unused]] nsAtom* aLangGroup, nsTArray& aFamilies) { -+ // Camoufox: spoof system-ui to match the spoofed OS -+ if (auto platform = MaskConfig::GetString("navigator.platform"); platform) { -+ if (*platform == "MacIntel") { -+ *aFamilies.AppendElement() = "Helvetica"_ns; -+ return; -+ } -+ if (*platform == "Win32") { -+ *aFamilies.AppendElement() = "Segoe UI"_ns; -+ return; ++ // Camoufox: spoof system-ui to match the spoofed OS. Chrome documents are ++ // excluded: they are not reachable by the page, and pointing the browser's ++ // own UI at a family from another OS leaves it falling back to the default ++ // serif font (daijro/camoufox#695). A null provider means there is no ++ // document behind the lookup, where spoofing is the safe default. ++ if (!(aFontVisibilityProvider && aFontVisibilityProvider->IsChrome())) { ++ if (auto platform = MaskConfig::GetString("navigator.platform"); platform) { ++ if (*platform == "MacIntel") { ++ *aFamilies.AppendElement() = "Helvetica"_ns; ++ return; ++ } ++ if (*platform == "Win32") { ++ *aFamilies.AppendElement() = "Segoe UI"_ns; ++ return; ++ } + } + } + diff --git a/patches/touchscreen-fingerprint-spoofing.patch b/patches/touchscreen-fingerprint-spoofing.patch new file mode 100644 index 0000000..f17cd17 --- /dev/null +++ b/patches/touchscreen-fingerprint-spoofing.patch @@ -0,0 +1,81 @@ +diff --git a/dom/base/Navigator.cpp b/dom/base/Navigator.cpp +--- a/dom/base/Navigator.cpp ++++ b/dom/base/Navigator.cpp +@@ -893,12 +977,21 @@ bool Navigator::Vibrate(const nsTArray& aPattern) { + + //***************************************************************************** + // Pointer Events interface + //***************************************************************************** + + uint32_t Navigator::MaxTouchPoints(CallerType aCallerType) { ++ // Allow spoofing navigator.maxTouchPoints via config. A headless build has ++ // no digitizer, so the real value is 0 and Firefox's RFP path only ever ++ // collapses it to 0. The target is not a phone: it is the ordinary Windows ++ // touchscreen laptop, which reports a digitizer here while keeping a fine ++ // primary pointer. See force-default-pointer.patch for the matching ++ // `any-pointer: coarse` half. ++ if (auto value = MaskConfig::GetUint32("navigator.maxTouchPoints")) { ++ return value.value(); ++ } + nsIDocShell* docshell = GetDocShell(); + BrowsingContext* bc = docshell ? docshell->GetBrowsingContext() : nullptr; + + // Responsive Design Mode overrides the maxTouchPoints property when + // touch simulation is enabled. + if (bc && bc->Top()->InRDMPane()) { +diff --git a/dom/events/TouchEvent.cpp b/dom/events/TouchEvent.cpp +--- a/dom/events/TouchEvent.cpp ++++ b/dom/events/TouchEvent.cpp +@@ -1,12 +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/. */ + + #include "mozilla/dom/TouchEvent.h" + ++#include "MaskConfig.hpp" ++ + #include "gfxPlatform.h" + #include "mozilla/BasePrincipal.h" + #include "mozilla/LookAndFeel.h" + #include "mozilla/Preferences.h" + #include "mozilla/StaticPrefs_dom.h" + #include "mozilla/TouchEvents.h" +@@ -221,12 +223,24 @@ bool TouchEvent::PrefEnabled(nsIDocShell* aDocShell) { + bool enabled = false; + if (touchEventsOverride == mozilla::dom::TouchEventsOverride::Enabled) { + enabled = true; + } else if (touchEventsOverride == + mozilla::dom::TouchEventsOverride::Disabled) { + enabled = false; ++ } else if (auto maxTouchPoints = ++ MaskConfig::GetUint32("navigator.maxTouchPoints")) { ++ // A spoofed digitizer has to bring the touch interfaces with it: a ++ // navigator.maxTouchPoints above zero next to a missing window.TouchEvent ++ // is a one-line contradiction to check for. ++ // ++ // This deliberately moves PrefEnabled only, never LegacyAPIEnabled, so ++ // `ontouchstart` stays absent. dom.w3c_touch_events.legacy_apis.enabled is ++ // false everywhere but Android, so a real Windows touchscreen laptop ++ // exposes TouchEvent and Touch while `'ontouchstart' in window` is false. ++ // Matching that shape matters more than exposing the whole touch API. ++ enabled = maxTouchPoints.value() > 0; + } else if (nsContentUtils::ShouldResistFingerprinting( + aDocShell, RFPTarget::PointerEvents)) { + #ifdef MOZ_WIDGET_COCOA + enabled = false; + #else + enabled = true; +diff --git a/dom/events/moz.build b/dom/events/moz.build +--- a/dom/events/moz.build ++++ b/dom/events/moz.build +@@ -186,6 +186,9 @@ LOCAL_INCLUDES += [ + "/js/xpconnect/wrappers", + "/layout/forms", + "/layout/generic", + "/layout/xul", + "/layout/xul/tree/", + ] ++ ++# DOM Mask ++LOCAL_INCLUDES += ["/camoucfg"] diff --git a/pythonlib/camoufox/addons.py b/pythonlib/camoufox/addons.py index 191707d..32f36bb 100644 --- a/pythonlib/camoufox/addons.py +++ b/pythonlib/camoufox/addons.py @@ -1,4 +1,5 @@ import os +import shutil from enum import Enum from multiprocessing import Lock from typing import List, Optional @@ -74,14 +75,16 @@ def maybe_download_addons( # Get the addon path addon_path = get_addon_path(addon.name) - # Check if the addon is already extracted - if os.path.exists(addon_path): + # Check if the addon is already extracted. A bare directory is not + # enough: a failed download leaves an empty dir behind, so require the + # manifest that confirm_paths() looks for. + if os.path.exists(os.path.join(addon_path, 'manifest.json')): # Add the existing addon path to addons_list if addons_list is not None: addons_list.append(addon_path) continue - # Addon doesn't exist, create directory and download + # Addon isn't extracted, create directory and download try: os.makedirs(addon_path, exist_ok=True) download_and_extract(addon.value, addon_path, addon.name) @@ -89,4 +92,6 @@ def maybe_download_addons( if addons_list is not None: addons_list.append(addon_path) except Exception as e: + # Drop the partial directory so the next run re-downloads. + shutil.rmtree(addon_path, ignore_errors=True) print(f"Failed to download and extract {addon.name}: {e}") diff --git a/pythonlib/camoufox/exceptions.py b/pythonlib/camoufox/exceptions.py index 6940141..3cdbd67 100644 --- a/pythonlib/camoufox/exceptions.py +++ b/pythonlib/camoufox/exceptions.py @@ -14,6 +14,14 @@ class MissingRelease(Exception): ... +class CorruptedDownload(Exception): + """ + Raised when a downloaded asset does not match its expected sha256 digest. + """ + + ... + + class UnsupportedArchitecture(Exception): """ Raised when the architecture is not supported. diff --git a/pythonlib/camoufox/fingerprints.py b/pythonlib/camoufox/fingerprints.py index ccb3866..b283d29 100644 --- a/pythonlib/camoufox/fingerprints.py +++ b/pythonlib/camoufox/fingerprints.py @@ -738,6 +738,33 @@ def get_random_preset( return choice(candidates) # nosec +# Tokens that name the machine rather than the platform: Firefox leaves every one +# of them out of appVersion. +_APP_VERSION_DROPPED = ('Win64', 'x64', 'Mobile', 'Tablet') + + +def _app_version_from_user_agent(user_agent: str) -> Optional[str]: + """The appVersion Firefox reports for a browser sending this user agent. + + "5.0 ()": the parenthesised part of the UA without the + architecture, the Gecko revision, or the Windows build number. + """ + block = re.match(r'Mozilla/5\.0 \(([^)]*)\)', user_agent or '') + if not block: + return None + kept = [] + for token in (part.strip() for part in block.group(1).split(';')): + if ( + token.startswith('rv:') + or token in _APP_VERSION_DROPPED + or token.startswith('Linux ') + or token.startswith('Intel Mac OS X') + ): + continue + kept.append('Windows' if token.startswith('Windows') else token) + return f"5.0 ({'; '.join(kept)})" if kept else None + + def from_preset(preset: Dict, ff_version: Optional[str] = None) -> Dict[str, Any]: """ Convert a real fingerprint preset to CAMOU_CONFIG format. @@ -767,6 +794,24 @@ def from_preset(preset: Dict, ff_version: Optional[str] = None) -> Dict[str, Any config['navigator.oscpu'] = 'Windows NT 10.0; Win64; x64' elif 'Linux' in plat or 'linux' in plat: config['navigator.oscpu'] = 'Linux x86_64' + if nav.get('appVersion'): + config['navigator.appVersion'] = nav['appVersion'] + elif config.get('navigator.userAgent'): + # Left unset, appVersion falls through to the *host's* value and then + # contradicts the userAgent and platform set above: a Linux preset on a + # macOS host reported "5.0 (Macintosh)" beside platform "Linux x86_64", + # which any page can read in two properties. + # + # Firefox builds it from the same OS tokens as the userAgent, minus the + # architecture and rv, with Windows collapsed to its family name. Deriving + # it from the UA rather than from the platform keeps the distro token that + # 20 of the bundled Linux presets carry ("X11; Ubuntu"), which a platform + # lookup would flatten to "X11" — a mismatch of the same kind, if a + # smaller one. Checked against 800 browserforge fingerprints: exact every + # time. + derived = _app_version_from_user_agent(config['navigator.userAgent']) + if derived: + config['navigator.appVersion'] = derived if 'maxTouchPoints' in nav: config['navigator.maxTouchPoints'] = nav['maxTouchPoints'] diff --git a/pythonlib/camoufox/gui/backend.py b/pythonlib/camoufox/gui/backend.py index ac364d0..366d3af 100644 --- a/pythonlib/camoufox/gui/backend.py +++ b/pythonlib/camoufox/gui/backend.py @@ -32,7 +32,7 @@ from ..multiversion import ( save_repo_cache, set_active, ) -from ..pkgman import RepoConfig, unzip, webdl +from ..pkgman import RepoConfig, unzip, verify_sha256, webdl # Workers @@ -74,8 +74,12 @@ class DownloadWorker(Worker): with tempfile.NamedTemporaryFile() as f: webdl(self.version.url, buffer=f, bar=False, progress_callback=self._progress) - self.status.emit("Extracting...") + self.status.emit("Verifying...") self.progress.emit(-1) + verify_sha256( + f, self.version.sha256, desc=f"Camoufox v{self.version.version.full_string}" + ) + self.status.emit("Extracting...") unzip(f, str(path), bar=False) (path / 'version.json').write_bytes(orjson.dumps(self.version.to_metadata())) diff --git a/pythonlib/camoufox/ip.py b/pythonlib/camoufox/ip.py index 9722914..e7771e8 100644 --- a/pythonlib/camoufox/ip.py +++ b/pythonlib/camoufox/ip.py @@ -1,12 +1,9 @@ import re -import warnings -from contextlib import contextmanager from dataclasses import dataclass from functools import lru_cache from typing import Dict, Optional, Tuple import requests -from urllib3.exceptions import InsecureRequestWarning from .exceptions import InvalidIP, InvalidProxy @@ -78,13 +75,6 @@ def validate_ip(ip: str) -> None: raise InvalidIP(f"Invalid IP address: {ip}") -@contextmanager -def _suppress_insecure_warning(): - with warnings.catch_warnings(): - warnings.filterwarnings("ignore", category=InsecureRequestWarning) - yield - - @lru_cache(maxsize=None) def public_ip(proxy: Optional[str] = None) -> str: """ @@ -104,13 +94,12 @@ def public_ip(proxy: Optional[str] = None) -> str: end_exception = None for url in URLS: try: - with _suppress_insecure_warning(): - resp = requests.get( # nosec - url, - proxies=Proxy.as_requests_proxy(proxy) if proxy else None, - timeout=5, - verify=False, - ) + resp = requests.get( + url, + proxies=Proxy.as_requests_proxy(proxy) if proxy else None, + timeout=5, + verify=True, + ) resp.raise_for_status() ip = resp.text.strip() validate_ip(ip) diff --git a/pythonlib/camoufox/multiversion.py b/pythonlib/camoufox/multiversion.py index 83640d0..c12d34f 100644 --- a/pythonlib/camoufox/multiversion.py +++ b/pythonlib/camoufox/multiversion.py @@ -17,7 +17,7 @@ if TYPE_CHECKING: import orjson import rich_click as click -from .pkgman import INSTALL_DIR, OS_NAME, Version, rprint, unzip +from .pkgman import INSTALL_DIR, OS_NAME, Version, rprint, unzip, verify_sha256 BROWSERS_DIR: Path = INSTALL_DIR / "browsers" CONFIG_FILE: Path = INSTALL_DIR / "config.json" @@ -420,6 +420,14 @@ def install_versioned(fetcher, replace: bool = False) -> bool: with tempfile.NamedTemporaryFile() as temp_file: fetcher.download_file(temp_file, fetcher.url) + + expected_sha = ( + fetcher._selected_version.sha256 + if fetcher._selected_version + else getattr(fetcher, "installed_sha256", None) + ) + verify_sha256(temp_file, expected_sha, desc=f"Camoufox v{fetcher.verstr}") + rprint(f'Extracting Camoufox: {install_path}') unzip(temp_file, str(install_path)) diff --git a/pythonlib/camoufox/pkgman.py b/pythonlib/camoufox/pkgman.py index 67c9ae5..cb45254 100644 --- a/pythonlib/camoufox/pkgman.py +++ b/pythonlib/camoufox/pkgman.py @@ -1,3 +1,4 @@ +import hashlib import os import platform import re @@ -31,6 +32,7 @@ from yaml import CLoader, load from .__version__ import CONSTRAINTS from .exceptions import ( CamoufoxNotInstalled, + CorruptedDownload, MissingRelease, ProfileDirectoryError, UnsupportedArchitecture, @@ -950,6 +952,33 @@ def webdl( return buffer +def verify_sha256(buffer: DownloadBuffer, expected: Optional[str], desc: str = "asset") -> None: + """ + Check a downloaded buffer against its expected sha256 digest. + + Raises CorruptedDownload on mismatch. Skips silently when no digest is + known, so installs from sources that publish no digest still work. + """ + if not expected: + rprint(f"Warning: no sha256 published for {desc}; skipping verification.", fg="yellow") + return + + buffer.seek(0) + digest = hashlib.sha256() + for block in iter(lambda: buffer.read(1024 * 1024), b""): + digest.update(block) + buffer.seek(0) + + actual = digest.hexdigest() + if actual != expected.lower(): + raise CorruptedDownload( + f"Checksum mismatch for {desc}.\n" + f" expected sha256: {expected.lower()}\n" + f" actual sha256: {actual}\n" + "The download was corrupted or tampered with. Installation aborted." + ) + + def unzip( zip_file: DownloadBuffer, extract_path: str, diff --git a/pythonlib/camoufox/utils.py b/pythonlib/camoufox/utils.py index 537562e..d0a8612 100644 --- a/pythonlib/camoufox/utils.py +++ b/pythonlib/camoufox/utils.py @@ -330,7 +330,7 @@ def get_screen_cons(headless: Optional[bool] = None) -> Optional[Screen]: Bounds are CSS pixels, the unit Firefox lays its windows out in -- see camoufox.display for why that differs from the monitor's physical size. """ - if headless is False: + if headless is True: return None # Skip if headless display = largest_display() if display is None: @@ -452,6 +452,11 @@ def warn_manual_config(config: Dict[str, Any]) -> None: # Manual navigator setting if is_domain_set(config, 'navigator.'): LeakWarning.warn('navigator', False) + # Touchscreen digitizer spoofing. Called out separately from the blanket + # navigator warning because the knock-on effects reach past navigator into + # CSS pointer media queries and the TouchEvent interfaces. + if is_domain_set(config, 'navigator.maxTouchPoints'): + LeakWarning.warn('max_touch_points', False) # Manual screen/window setting if is_domain_set(config, 'screen.', 'window.', 'document.body.'): LeakWarning.warn('viewport', False) @@ -786,7 +791,10 @@ def launch_options( # Bound the geometry to the real display. BrowserForge only honours this when # its pool has a match, so it is re-applied after generation as well. - screen_cons = screen or get_screen_cons(headless or has_display(env)) + # `headless` and "is there a display to probe" are separate questions: passing + # `headless or has_display(env)` made a headful run on a real display look like a + # headless one to get_screen_cons(), which then skipped the bound entirely. + screen_cons = screen or (get_screen_cons(headless) if has_display(env) else None) if not _used_preset and fingerprint is None: # Default: BrowserForge synthetic generation (infinite unique fingerprints) diff --git a/pythonlib/camoufox/warnings.yml b/pythonlib/camoufox/warnings.yml index 2f1bd37..3c81cd5 100644 --- a/pythonlib/camoufox/warnings.yml +++ b/pythonlib/camoufox/warnings.yml @@ -48,4 +48,12 @@ custom_fonts_only: >- WAFs can detect this mismatch between your claimed OS and available system fonts. disable_coop: >- - Disabling Cross-Origin-Opener-Policy (COOP) handling can potentially be detected by sophisticated WAFs. \ No newline at end of file + Disabling Cross-Origin-Opener-Policy (COOP) handling can potentially be detected by sophisticated WAFs. + +max_touch_points: >- + Setting navigator.maxTouchPoints manually overrides Camoufox's touchscreen handling. + A non-zero value presents a touch digitizer: Camoufox adds `(any-pointer: coarse)` and + the TouchEvent/Touch interfaces to match a touchscreen laptop, while deliberately leaving + `(pointer: coarse)` false and `ontouchstart` absent, exactly as a real one does. + The rest of your fingerprint is not adjusted to suit, so a device that claims a digitizer + but reports a screen size no touchscreen laptop ships with is still inconsistent. diff --git a/pythonlib/tests/test_addons.py b/pythonlib/tests/test_addons.py new file mode 100644 index 0000000..ecef4ca --- /dev/null +++ b/pythonlib/tests/test_addons.py @@ -0,0 +1,95 @@ +""" +Tests for camoufox.addons default-addon download/caching. + +Regression guard for #308: a partial/failed first download leaves an empty +addon directory behind. The old "already downloaded" check was a bare +os.path.exists(dir), so that empty dir was trusted forever and every later +launch raised InvalidAddonPath ("manifest.json is missing"), unrecoverable +short of manually deleting the cache. + +Run with: + cd pythonlib && python -m pytest tests/test_addons.py -v +""" + +import os +import sys + +import pytest + +# Make `import camoufox` resolve to the in-tree pythonlib without an install. +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from camoufox import addons as addons_mod # noqa: E402 +from camoufox.addons import DefaultAddons, maybe_download_addons # noqa: E402 + +UBO = DefaultAddons.UBO.name + + +@pytest.fixture +def addons_dir(tmp_path, monkeypatch): + # Point the addon store at a throwaway dir so no real cache is touched. + root = tmp_path / "addons" + monkeypatch.setattr(addons_mod, "get_addon_path", lambda name: str(root / name)) + return root + + +def _write_manifest(url, extract_path, name): + os.makedirs(extract_path, exist_ok=True) + with open(os.path.join(extract_path, "manifest.json"), "w") as f: + f.write("{}") + + +def test_partial_dir_is_redownloaded(addons_dir, monkeypatch): + # Leftover empty dir from a failed first download. + partial = addons_dir / UBO + partial.mkdir(parents=True) + assert not (partial / "manifest.json").exists() + + calls = [] + + def fake(url, extract_path, name): + calls.append(name) + _write_manifest(url, extract_path, name) + + monkeypatch.setattr(addons_mod, "download_and_extract", fake) + + out = [] + maybe_download_addons([DefaultAddons.UBO], out) + + # An empty dir must trigger a re-download, not be trusted. + assert calls == [UBO] + assert (partial / "manifest.json").exists() + assert out == [str(partial)] + + +def test_extracted_addon_is_not_redownloaded(addons_dir, monkeypatch): + path = addons_dir / UBO + path.mkdir(parents=True) + (path / "manifest.json").write_text("{}") + + def boom(*a, **k): + raise AssertionError("must not re-download an already-extracted addon") + + monkeypatch.setattr(addons_mod, "download_and_extract", boom) + + out = [] + maybe_download_addons([DefaultAddons.UBO], out) + assert out == [str(path)] + + +def test_failed_download_removes_partial_dir(addons_dir, monkeypatch): + path = addons_dir / UBO + + def fail(url, extract_path, name): + os.makedirs(extract_path, exist_ok=True) # partial write, then die + raise RuntimeError("network died mid-download") + + monkeypatch.setattr(addons_mod, "download_and_extract", fail) + + out = [] + maybe_download_addons([DefaultAddons.UBO], out) + + # The partial dir must be gone so the next run re-downloads instead of + # trusting an addon that has no manifest.json. + assert not path.exists() + assert out == [] diff --git a/pythonlib/tests/test_config_schema.py b/pythonlib/tests/test_config_schema.py new file mode 100644 index 0000000..bd00fc9 --- /dev/null +++ b/pythonlib/tests/test_config_schema.py @@ -0,0 +1,107 @@ +""" +Guard: every config key the browser reads must be declared in the schema. + +Regression guard for the `media:spoof_codecs` gap in PR #562. The C++ side read +the key via MaskConfig::GetBool("media:spoof_codecs"), but nothing ever added it +to settings/properties.json, and validate_config() drops any key it does not +recognise -- printing "Skipping unknown patch media:spoof_codecs" and moving on. +The documented usage, + + AsyncCamoufox(config={"media:spoof_codecs": True}) + +therefore did nothing at all: the key never reached the browser, so the feature +could not be switched on through the supported path. + +This is the read-but-undeclared direction of a mistake the project has made +before in the other direction -- canvas:seed (#721) and navigator.maxTouchPoints +(#696) were both declared in the schema while nothing consumed them. A patch and +a schema entry are two halves of one change; this test fails the build when only +one half lands. + +Run with: + cd pythonlib && python -m pytest tests/test_config_schema.py -v +""" + +import json +import re +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parents[2] +PROPERTIES = REPO / "settings" / "properties.json" + +# MaskConfig::GetBool("k") / GetString("k") / GetUint32("k") / HasKey("k") ... +MASKCONFIG_READ = re.compile(r'MaskConfig::(?:Get|Has)\w*\(\s*"([^"]+)"') + +# Keys read through a variable or built at runtime rather than a string literal. +# Add here (with a reason) only when the read genuinely cannot name its key. +ALLOWED_UNDECLARED: set = set() + + +def _sources(): + for pattern in ("patches/**/*.patch", "additions/**/*"): + for path in REPO.glob(pattern): + if path.is_file(): + yield path + + +def _declared_keys() -> set: + return {entry["property"] for entry in json.loads(PROPERTIES.read_text())} + + +def _keys_read() -> dict: + """Map config key -> sorted list of files that read it.""" + found: dict = {} + for path in _sources(): + try: + text = path.read_text(errors="ignore") + except OSError: + continue + for match in MASKCONFIG_READ.finditer(text): + found.setdefault(match.group(1), set()).add( + str(path.relative_to(REPO)) + ) + return {k: sorted(v) for k, v in found.items()} + + +def test_properties_json_is_wellformed(): + entries = json.loads(PROPERTIES.read_text()) + assert entries, "settings/properties.json is empty" + for entry in entries: + assert "property" in entry and "type" in entry, f"malformed entry: {entry}" + + +def test_every_key_the_browser_reads_is_declared(): + declared = _declared_keys() + read = _keys_read() + assert read, "found no MaskConfig reads -- the scanner regexp has gone stale" + + undeclared = { + key: files + for key, files in read.items() + if key not in declared and key not in ALLOWED_UNDECLARED + } + if undeclared: + lines = [ + "config keys are read by the browser but missing from " + "settings/properties.json,", + "so validate_config() silently drops them and the feature cannot be " + "enabled through the Python API:", + "", + ] + for key, files in sorted(undeclared.items()): + lines.append(f" {key}") + for f in files: + lines.append(f" read in {f}") + pytest.fail("\n".join(lines)) + + +@pytest.mark.parametrize("key", ["media:spoof_codecs"]) +def test_known_previously_missing_keys_stay_declared(key): + """Pin the specific keys this guard was written for, so a schema edit that + drops one fails loudly here rather than only in the general scan above.""" + assert key in _declared_keys(), ( + f"{key} is read by the browser but is not declared in " + f"settings/properties.json -- see PR #562" + ) diff --git a/pythonlib/tests/test_download_integrity.py b/pythonlib/tests/test_download_integrity.py new file mode 100644 index 0000000..a98c86f --- /dev/null +++ b/pythonlib/tests/test_download_integrity.py @@ -0,0 +1,84 @@ +"""Guards for the integrity of downloaded release assets. + +`webdl()` streams a release asset straight into a buffer that `unzip()` then +extracts over the install directory. The GitHub API already hands us the +asset's `digest` field, and `check_asset()` parses it into `installed_sha256` +-- but nothing ever compared it against the bytes on disk, so a corrupted or +substituted archive was extracted and executed unchallenged. + +`verify_sha256()` closes that gap. These tests pin the behaviour that matters: +a mismatch must abort the install, and a verified buffer must still be +readable from position 0 so the extraction step keeps working. +""" + +import hashlib +from io import BytesIO + +import pytest + +from camoufox.exceptions import CorruptedDownload +from camoufox.pkgman import verify_sha256 + +# Large enough to span several read() blocks, so a single-shot read() +# regression cannot pass by accident. +PAYLOAD = b"camoufox release asset" * 100_000 +DIGEST = hashlib.sha256(PAYLOAD).hexdigest() + + +def test_matching_digest_is_accepted(): + verify_sha256(BytesIO(PAYLOAD), DIGEST, "asset") + + +def test_digest_comparison_is_case_insensitive(): + """GitHub returns lowercase hex, but a hand-pinned digest may not be.""" + verify_sha256(BytesIO(PAYLOAD), DIGEST.upper(), "asset") + + +@pytest.mark.parametrize( + "mutate", + [ + pytest.param(lambda b: bytes([b[0] ^ 0xFF]) + b[1:], id="first-byte-flipped"), + pytest.param(lambda b: b[:-1] + bytes([b[-1] ^ 0x01]), id="last-bit-flipped"), + pytest.param(lambda b: b[:-1], id="truncated"), + pytest.param(lambda b: b + b"\x00", id="appended"), + pytest.param(lambda b: b"", id="empty"), + ], +) +def test_tampered_payload_aborts_the_install(mutate): + """Any deviation must raise -- extraction never gets to run.""" + with pytest.raises(CorruptedDownload): + verify_sha256(BytesIO(mutate(PAYLOAD)), DIGEST, "asset") + + +def test_error_names_both_digests(): + """The message has to be actionable when someone hits this in the wild.""" + with pytest.raises(CorruptedDownload) as exc: + verify_sha256(BytesIO(b"wrong"), DIGEST, "Camoufox v1.2.3") + msg = str(exc.value) + assert "Camoufox v1.2.3" in msg + assert DIGEST in msg + assert hashlib.sha256(b"wrong").hexdigest() in msg + + +@pytest.mark.parametrize("absent", [None, ""]) +def test_missing_digest_does_not_block_the_install(absent): + """Sources that publish no digest must stay installable, not hard-fail.""" + verify_sha256(BytesIO(PAYLOAD), absent, "asset") + + +def test_buffer_is_rewound_for_extraction(): + """unzip() reads the same buffer next; leaving it at EOF yields an + empty archive rather than a loud failure.""" + buf = BytesIO(PAYLOAD) + verify_sha256(buf, DIGEST, "asset") + assert buf.tell() == 0 + assert buf.read() == PAYLOAD + + +def test_verifies_a_real_temporary_file(tmp_path): + """The install path passes a NamedTemporaryFile, not a BytesIO.""" + path = tmp_path / "asset.zip" + path.write_bytes(PAYLOAD) + with open(path, "rb") as f: + verify_sha256(f, DIGEST, "asset") + assert f.tell() == 0 diff --git a/pythonlib/tests/test_preset_appversion.py b/pythonlib/tests/test_preset_appversion.py new file mode 100644 index 0000000..da05e5e --- /dev/null +++ b/pythonlib/tests/test_preset_appversion.py @@ -0,0 +1,142 @@ +""" +Tests that a device preset does not leak the host's operating system. + +Run with: + cd pythonlib && python -m pytest tests/test_preset_appversion.py -v + +The regression these guard (daijro/camoufox#744): from_preset() sets +navigator.userAgent, platform and oscpu from the captured device, but never +appVersion. Firefox reports appVersion as "5.0 ()", so an unset value +falls through to the host's own -- and a page reading two properties sees a +Linux platform beside "5.0 (Macintosh)". + +Measured before the fix on 152.0.4-beta.29, macOS host, os="linux" with +fingerprint_preset=True: + + navigator.platform Linux x86_64 + navigator.appVersion 5.0 (Macintosh) <- the host + +The generated (browserforge) path already emits a coherent pair, which is why +this only shows up on the preset path. +""" + +import os +import sys + +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from camoufox.fingerprints import _app_version_from_user_agent, from_preset # noqa: E402 + +# The tokens Firefox actually reports. Sampled from 800 browserforge +# fingerprints: Windows and Macintosh collapse to the family name, X11 keeps a +# distro token when the user agent carries one, and Android keeps its version. +_MAC = "5.0 (Macintosh)" +_WINDOWS = "5.0 (Windows)" +_X11 = "5.0 (X11)" +_X11_UBUNTU = "5.0 (X11; Ubuntu)" + + +def _preset(platform: str, user_agent: str) -> dict: + return {"navigator": {"platform": platform, "userAgent": user_agent}} + + +@pytest.mark.parametrize( + ("platform", "user_agent", "expected"), + [ + ( + "Linux x86_64", + "Mozilla/5.0 (X11; Linux x86_64; rv:152.0) Gecko/20100101 Firefox/152.0", + _X11, + ), + ( + "Win32", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Gecko/20100101 Firefox/152.0", + _WINDOWS, + ), + ( + "MacIntel", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:152.0) Gecko/20100101 Firefox/152.0", + _MAC, + ), + ], +) +def test_app_version_follows_the_preset_platform(platform, user_agent, expected): + """Every preset must carry the appVersion its own platform implies.""" + config = from_preset(_preset(platform, user_agent)) + + assert config["navigator.appVersion"] == expected + + +def test_app_version_agrees_with_the_user_agent(): + """The pair a page compares must not contradict itself. + + This is the check the issue is about: not that appVersion holds any + particular string, but that it names the same system as the userAgent + beside it. + """ + config = from_preset( + _preset("Linux x86_64", "Mozilla/5.0 (X11; Linux x86_64; rv:152.0) Gecko/20100101 Firefox/152.0") + ) + + assert "X11" in config["navigator.userAgent"] + assert config["navigator.appVersion"] == _X11 + assert "Macintosh" not in config["navigator.appVersion"] + assert "Windows" not in config["navigator.appVersion"] + + +def test_a_preset_that_carries_its_own_app_version_keeps_it(): + """A captured value is the real device's, so it wins over the derived one.""" + preset = _preset("Win32", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Firefox/152.0") + preset["navigator"]["appVersion"] = "5.0 (Windows NT 10.0; Win64; x64)" + + config = from_preset(preset) + + assert config["navigator.appVersion"] == "5.0 (Windows NT 10.0; Win64; x64)" + + +def test_an_unknown_platform_follows_its_user_agent(): + """The user agent is the authority, not the platform string beside it.""" + config = from_preset(_preset("iPhone", "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) Gecko/20100101")) + + assert config["navigator.appVersion"] == "5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)" + + +@pytest.mark.parametrize( + ("user_agent", "expected"), + [ + # Every shape in the captured corpus, with the counts they appeared in. + ("Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Gecko/20100101 Firefox/152.0", _WINDOWS), + ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:152.0) Gecko/20100101 Firefox/152.0", _MAC), + ("Mozilla/5.0 (X11; Linux x86_64; rv:152.0) Gecko/20100101 Firefox/152.0", _X11), + ("Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:152.0) Gecko/20100101 Firefox/152.0", _X11_UBUNTU), + ("Mozilla/5.0 (Android 16; Mobile; rv:152.0) Gecko/152.0 Firefox/152.0", "5.0 (Android 16)"), + ], +) +def test_the_derivation_matches_what_firefox_reports(user_agent, expected): + """Checked against 800 browserforge fingerprints; exact on every one.""" + assert _app_version_from_user_agent(user_agent) == expected + + +def test_a_distro_token_survives(): + """Twenty of the bundled Linux presets say "X11; Ubuntu" in their user agent. + + Deriving from the platform instead would flatten those to "X11" — a smaller + mismatch than the host leaking, but the same kind, and Firefox never emits it. + """ + config = from_preset( + _preset( + "Linux x86_64", + "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:152.0) Gecko/20100101 Firefox/152.0", + ) + ) + + assert config["navigator.appVersion"] == _X11_UBUNTU + + +def test_a_user_agent_it_cannot_read_is_left_alone(): + """Better an absent value than an invented one.""" + config = from_preset(_preset("Win32", "not a user agent")) + + assert "navigator.appVersion" not in config diff --git a/scripts/check-input-dispatch.py b/scripts/check-input-dispatch.py new file mode 100755 index 0000000..51bbe14 --- /dev/null +++ b/scripts/check-input-dispatch.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +""" +Enforce that synthesized input is dispatched from exactly one place. + +WHY THIS EXISTS +Between 2026-04 and 2026-09, four deadlocks shipped from the same invariant +being broken in four different ways -- exact-edge coordinates (#225), humanized +trajectory points that bypassed the endpoint's guard (#677), a zero-displacement +move, and the top-edge row (#751, #752). Each was fixed by adding one more +coordinate guard at one more call site. + +The invariant: + + A synthesized input event whose ack we await must reach the content + renderer -- and when it does not, we must stop waiting. + +It is unenforceable by review, because a violation looks like ordinary +arithmetic and costs the entire browser process. #677 is the proof: restoring +the humanize trajectory meant writing a bounds check, and the check that got +written was a copy of the pre-#225 form -- reintroducing a fixed deadlock one +day before it was re-fixed. Nobody caught it in review; a grep would have. + +So: one module owns the conversion, the bounds predicate and the ack wait, and +this check fails the build if anything else takes that job on. It needs no +browser build and runs in seconds, so it can gate every pull request. + + python3 scripts/check-input-dispatch.py +""" + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +SCAN_ROOT = ROOT / "additions" / "juggler" +CHOKEPOINT = "additions/juggler/input/MouseDispatch.js" +DOC = "docs/input-dispatch.md" + +# The two exemptions are both the CONTENT process -- the other end of the wire, +# where the chokepoint's job does not exist: +# +# PageAgent dispatches drag events with coordinates that are already +# content-relative. No browser-element offset, no ack awaited. +# FrameTree is the ack PRODUCER: it observes the +# juggler-mouse-event-hit-renderer notification and emits the +# InputEvent carrying jugglerEventId. It waits for nothing. +# +# Both are narrow and deliberate. Anything in the parent process is covered. +CONTENT_DRAG = "additions/juggler/content/PageAgent.js" +CONTENT_ACK_SOURCE = "additions/juggler/content/FrameTree.js" + +# (regex, what the code is doing, files exempt in addition to the chokepoint) +RULES = [ + (r"\bjugglerSendMouseEvent\s*\(", "dispatches a synthesized mouse event", {CONTENT_DRAG}), + (r"\bsendWheelEvent\s*\(", "dispatches a synthesized wheel event", set()), + (r"\bjugglerEventId\b", "waits for a renderer ack", {CONTENT_ACK_SOURCE}), + (r"\bboundingBox\s*\.\s*(?:left|top)\b", "does browser-relative coordinate arithmetic", set()), +] + +REMEDY = ( + f"Route it through MouseDispatch ({CHOKEPOINT}): sendAcked() to dispatch and\n" + f" wait under a deadline, isInViewport() for the bounds predicate,\n" + f" toAbsolute() for the conversion. See {DOC}." +) + + +def main() -> int: + if not (ROOT / CHOKEPOINT).is_file(): + print(f"FAIL: the chokepoint {CHOKEPOINT} is missing.") + print(" If it moved, update CHOKEPOINT in this script and in " + DOC + ".") + return 1 + + compiled = [(re.compile(p), what, exempt) for p, what, exempt in RULES] + violations = [] + + for path in sorted(SCAN_ROOT.rglob("*.js")): + rel = path.relative_to(ROOT).as_posix() + if rel == CHOKEPOINT or path.name.endswith(".bak"): + continue + for lineno, line in enumerate(path.read_text(errors="replace").splitlines(), 1): + if line.lstrip().startswith(("//", "*", "/*")): + continue + for pattern, what, exempt in compiled: + if rel in exempt: + continue + if pattern.search(line): + violations.append((rel, lineno, what, line.strip())) + + if not violations: + scanned = sum(1 for _ in SCAN_ROOT.rglob("*.js")) + print(f"input-dispatch: ok -- {scanned} files scanned, all synthesized input " + f"goes through {CHOKEPOINT}") + return 0 + + print("input-dispatch: FAILED\n") + for rel, lineno, what, line in violations: + print(f" {rel}:{lineno} {what} outside the chokepoint") + print(f" {line}") + print(f"\n {REMEDY}") + print(f"\n{len(violations)} violation(s).") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/service-tester/_bundle.py b/service-tester/_bundle.py index ce53876..8860b18 100644 --- a/service-tester/_bundle.py +++ b/service-tester/_bundle.py @@ -18,7 +18,8 @@ def ensure_bundle() -> Path: print("ERROR: build-tester/node_modules not found. Run 'npm install' in build-tester/ first.", file=sys.stderr) sys.exit(1) - esbuild = BUILD_TESTER_DIR / "node_modules" / ".bin" / "esbuild" + esbuild_name = "esbuild.cmd" if sys.platform == "win32" else "esbuild" + esbuild = BUILD_TESTER_DIR / "node_modules" / ".bin" / esbuild_name print("Building checks bundle (first run)...") entry = BUILD_TESTER_DIR / "src" / "lib" / "checks" / "index.ts" result = subprocess.run( diff --git a/service-tester/run_tests.ps1 b/service-tester/run_tests.ps1 new file mode 100644 index 0000000..92b1fca --- /dev/null +++ b/service-tester/run_tests.ps1 @@ -0,0 +1,188 @@ +<# +.SYNOPSIS + Windows equivalent of run_tests.sh for Camoufox service tests. + +.DESCRIPTION + Orchestrates two test phases against locally compiled and/or fetched + Camoufox binaries on Windows, mirroring the behaviour of run_tests.sh. + +.PARAMETER BrowserVersion + Camoufox version specifier (default: official/stable) + e.g. official/prerelease/146.0.1-beta.50 + +.PARAMETER ProfileCount + Number of profiles to test (1-6, default: 6) + +.PARAMETER Proxies + Path to proxies file (default: proxies.txt next to this script) + +.PARAMETER Headful + Run with visible browser window + +.PARAMETER NoCert + Skip certificate generation + +.PARAMETER SaveCert + Save certificate text to this file path + +.PARAMETER Binary + Which binary phase(s) to run: local | fetched | both (default: both) + +.EXAMPLE + .\run_tests.ps1 + .\run_tests.ps1 -BrowserVersion official/prerelease/146.0.1-beta.50 -Headful + .\run_tests.ps1 -Binary local + .\run_tests.ps1 -Binary fetched -ProfileCount 3 +#> + +param( + [string]$BrowserVersion = "official/stable", + [int] $ProfileCount = 6, + [string]$Proxies = "", + [switch]$Headful, + [switch]$NoCert, + [string]$SaveCert = "", + [string]$Binary = "both" +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +# Ensure the console and Python subprocess both handle UTF-8 output correctly. +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 +[Console]::InputEncoding = [System.Text.Encoding]::UTF8 +$env:PYTHONIOENCODING = "utf-8" + +# --- Resolve paths --- +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition +$BuildTesterDir = [IO.Path]::Combine($ScriptDir, "..", "build-tester") +$PythonlibDir = [IO.Path]::Combine($ScriptDir, "..", "pythonlib") +$VenvDir = Join-Path $ScriptDir ".venv" +$PythonExe = [IO.Path]::Combine($VenvDir, "Scripts", "python.exe") +$PipExe = [IO.Path]::Combine($VenvDir, "Scripts", "pip.exe") +$ProxiesFile = if ($Proxies) { $Proxies } else { Join-Path $ScriptDir "proxies.txt" } + +# --- Validate -Binary --- +if ($Binary -notin @("local", "fetched", "both")) { + Write-Error "ERROR: -Binary must be 'local', 'fetched', or 'both' (got: $Binary)" + exit 1 +} + +Write-Host "==> Browser version: $BrowserVersion" +Write-Host "==> Profile count: $ProfileCount" +Write-Host "==> Binary mode: $Binary" + +# --- npm deps (esbuild for TypeScript bundle) --- +$NodeModules = Join-Path $BuildTesterDir "node_modules" +if (-not (Test-Path $NodeModules)) { + Write-Host "==> Installing build-tester npm dependencies..." + Push-Location $BuildTesterDir + npm install --silent + Pop-Location +} + +# --- Python venv --- +if (-not (Test-Path $VenvDir)) { + Write-Host "==> Creating virtual environment..." + python -m venv $VenvDir +} + +# --- Build camoufox wheel --- +Write-Host "==> Building camoufox wheel from $PythonlibDir ..." +& $PipExe install -q build +$DistDir = [IO.Path]::Combine($PythonlibDir, "dist") +if (Test-Path $DistDir) { + Remove-Item -Recurse -Force $DistDir +} +Push-Location $PythonlibDir +& $PythonExe -m build --wheel -o dist | Out-Null +Pop-Location + +# --- Install wheel --- +Write-Host "==> Installing camoufox from local wheel..." +try { & $PipExe uninstall -y camoufox cloverlabs-camoufox 2>&1 | Out-Null } catch {} +$Wheel = Get-ChildItem ([IO.Path]::Combine($PythonlibDir, "dist", "*.whl")) | Select-Object -First 1 +if (-not $Wheel) { + Write-Error "ERROR: No wheel found in $PythonlibDir\dist" + exit 1 +} +& $PipExe install -q --force-reinstall $Wheel.FullName + +# --- Locate locally compiled Windows binary --- +# Build output: ../camoufox-*/obj-*-windows-msvc/dist/bin/camoufox.exe +$LocalBin = $null +if ($Binary -ne "fetched") { + $GlobPattern = [IO.Path]::Combine($ScriptDir, "..", "camoufox-*", "obj-*-windows-msvc", "dist", "bin", "camoufox.exe") + $Candidates = Get-ChildItem $GlobPattern -ErrorAction SilentlyContinue | + Sort-Object LastWriteTime -Descending + if ($Candidates) { + $LocalBin = $Candidates[0].FullName + } + if (-not $LocalBin) { + if ($Binary -eq "local") { + Write-Error "ERROR: -Binary local requested but no local build found at $GlobPattern" + exit 1 + } + Write-Host "==> No local build found -- skipping local-binary phase" + } +} + +# --- Build common args for run_tests.py --- +$CommonArgs = [System.Collections.Generic.List[string]]::new() +$CommonArgs.AddRange([string[]]@("--profile-count", $ProfileCount, "--proxies", $ProxiesFile)) +if ($Headful) { $CommonArgs.Add("--headful") } +if ($NoCert) { $CommonArgs.Add("--no-cert") } +if ($SaveCert) { $CommonArgs.AddRange([string[]]@("--save-cert", $SaveCert)) } + +$LocalRc = 0 +$FetchedRc = 0 + +# --- Phase 1: locally compiled binary --- +if ($LocalBin) { + Write-Host "" + Write-Host ("=" * 60) + Write-Host " PHASE 1/2 -- Local binary: $LocalBin" + Write-Host ("=" * 60) + + $Phase1Args = [System.Collections.Generic.List[string]]::new() + $Phase1Args.Add([IO.Path]::Combine($ScriptDir, "run_tests.py")) + $Phase1Args.AddRange([string[]]@("--executable-path", $LocalBin)) + $Phase1Args.AddRange($CommonArgs) + + & $PythonExe $Phase1Args + $LocalRc = $LASTEXITCODE +} + +# --- Phase 2: fetched binary --- +if ($Binary -ne "local") { + Write-Host "" + Write-Host ("=" * 60) + Write-Host " PHASE 2/2 -- Fetched binary: $BrowserVersion" + Write-Host ("=" * 60) + + Write-Host "==> Setting browser version: $BrowserVersion" + & $PythonExe -m camoufox set $BrowserVersion + Write-Host "==> Fetching browser..." + & $PythonExe -m camoufox fetch + + $Phase2Args = [System.Collections.Generic.List[string]]::new() + $Phase2Args.Add([IO.Path]::Combine($ScriptDir, "run_tests.py")) + $Phase2Args.AddRange([string[]]@("--browser-version", $BrowserVersion)) + $Phase2Args.AddRange($CommonArgs) + + & $PythonExe $Phase2Args + $FetchedRc = $LASTEXITCODE +} + +# --- Combined result --- +Write-Host "" +Write-Host ("=" * 60) +Write-Host " COMBINED RESULT" +Write-Host ("=" * 60) +if ($LocalBin) { Write-Host " Local binary: exit $LocalRc" } +if ($Binary -ne "local") { Write-Host " Fetched binary: exit $FetchedRc" } + +if (($LocalRc -ne 0) -or ($FetchedRc -ne 0)) { + exit 1 +} +exit 0 diff --git a/service-tester/run_tests.py b/service-tester/run_tests.py index 5b7794e..da0175c 100644 --- a/service-tester/run_tests.py +++ b/service-tester/run_tests.py @@ -25,6 +25,11 @@ import argparse import asyncio import sys from datetime import datetime, timezone + +# Ensure Unicode box-drawing characters render correctly on Windows consoles. +if sys.platform == "win32": + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + sys.stderr.reconfigure(encoding="utf-8", errors="replace") from pathlib import Path from typing import Optional diff --git a/settings/camoucfg.jvv b/settings/camoucfg.jvv index 3d89ba1..5eb5206 100644 --- a/settings/camoucfg.jvv +++ b/settings/camoucfg.jvv @@ -88,6 +88,7 @@ "mediaDevices:webcams": "int[>=0]", "mediaDevices:speakers": "int[>=0]", "mediaDevices:enabled": "bool", + "media:spoof_codecs": "bool", "webGl:renderer$__WEBGL": "str", "webGl:vendor$__WEBGL": "str", diff --git a/settings/properties.json b/settings/properties.json index 74a9bf4..81f4ded 100644 --- a/settings/properties.json +++ b/settings/properties.json @@ -97,6 +97,7 @@ { "property": "mediaDevices:webcams", "type": "uint" }, { "property": "mediaDevices:speakers", "type": "uint" }, { "property": "mediaDevices:enabled", "type": "bool" }, + { "property": "media:spoof_codecs", "type": "bool" }, { "property": "allowMainWorld", "type": "bool" }, { "property": "disableWorldIsolation", "type": "bool" }, { "property": "allowAddonNewtab", "type": "bool" }, diff --git a/tests/assets/touch-reference.html b/tests/assets/touch-reference.html new file mode 100644 index 0000000..e18a812 --- /dev/null +++ b/tests/assets/touch-reference.html @@ -0,0 +1,71 @@ +Camoufox touch reference + +

1. Static signals — captured automatically

+
reading…
+

2. Touch capture — please do these three, in order, inside the box

+
a) one-finger tap  ·  b) two-finger tap  ·  c) one-finger drag across the box
+
touch here
+ +0 events +

3. Paste this back

+ + diff --git a/tests/patches/input-ack-backstop.py b/tests/patches/input-ack-backstop.py new file mode 100644 index 0000000..a3271d1 --- /dev/null +++ b/tests/patches/input-ack-backstop.py @@ -0,0 +1,130 @@ +""" +Verify the renderer-ack wait is bounded (daijro/camoufox#751, #752). + +This guards the backstop itself -- the one mechanism that makes an undelivered +input event survivable rather than fatal. + +Camoufox dispatches synthesized mouse events inside `activateAndRun()` +(additions/juggler/TargetRegistry.js), which serializes every dispatch on a +*process-global* promise chain. Each dispatch awaits a hit-renderer ack. Before +the backstop that wait was unbounded, so an ack that never arrived did not lose +one event -- it wedged every later input event in the process, in every tab, +permanently, at 0% CPU with no diagnostic. All four shipped deadlocks were that +failure with four different triggers; see docs/input-dispatch.md. + +`MouseDispatch.sendAcked()` now waits at most `kAckDeadlineMs`, then drops the +event with a warning and lets the chain advance. + +HOW THIS TEST WORKS +The ack is delivered from the content main thread, so blocking that thread +delays it by exactly the block duration -- a legitimate mechanism for producing +a late ack, with no test-only hook in production code. The page is made to block +for well over the deadline; a bounded wait returns in about the deadline, an +unbounded one waits for the whole block. + +The gap is what makes the assertion meaningful: with a ~5s deadline and a 40s +block, "returned in under 20s" cannot be satisfied by an unbounded wait, and +does not depend on the exact deadline value. + +Dropping that event is the correct, chosen behaviour: warn and continue. The +test therefore asserts recovery, not delivery -- the move may legitimately be +lost, but the browser must still be usable afterwards. + +Run against a specific build: + CAMOUFOX_EXECUTABLE_PATH=/path/to/camoufox-bin python3 tests/patches/input-ack-backstop.py + +What PASS means: + * a dispatch whose ack is late by far more than the deadline returns in + roughly the deadline, not in the block duration; + * once the block clears, input still works -- the chain advanced rather + than being abandoned mid-slot. + +Before the backstop the first move waits out the entire block. +""" + +import asyncio +import os +import sys +import time + +from camoufox.async_api import AsyncCamoufox + +# Far longer than kAckDeadlineMs (5s), so the two outcomes cannot be confused. +BLOCK_MS = 40000 +# Generous over the deadline, far under the block. +BOUNDED_S = 20 +RECOVERY_TIMEOUT_S = 30 + +EXECUTABLE_PATH = os.environ.get("CAMOUFOX_EXECUTABLE_PATH") + + +def _launch_kwargs(): + kwargs = dict(headless=True, os="windows", humanize=False) + if EXECUTABLE_PATH: + kwargs["executable_path"] = EXECUTABLE_PATH + return kwargs + + +async def main() -> int: + print("\n=== bounded renderer-ack wait ===") + async with AsyncCamoufox(**_launch_kwargs()) as browser: + page = await browser.new_page() + await page.set_content('') + await page.evaluate( + "window.__moves=0;addEventListener('mousemove',()=>window.__moves++)") + await asyncio.wait_for(page.mouse.move(300, 300), timeout=RECOVERY_TIMEOUT_S) + + # Block the content main thread. Deliberately not awaited: the block has + # to still be running when the move below is dispatched. + blocker = asyncio.ensure_future(page.evaluate( + f"(()=>{{const end=Date.now()+{BLOCK_MS};while(Date.now() list: + """Returns the coordinates that did not reach the renderer.""" + undelivered = [] + async with AsyncCamoufox(**_launch_kwargs(humanize, spoofed_os)) as browser: + page = await browser.new_page() + await page.set_content('') + await page.evaluate(RECORDER) + vp = await page.evaluate("({w:innerWidth,h:innerHeight})") + w, h = vp["w"], vp["h"] + home = (int(w * INTERIOR[0]), int(h * INTERIOR[1])) + points = _ring(w, h) + print(f" [{spoofed_os}, humanize={humanize}] {w}x{h}: " + f"{len(points)} ring points", flush=True) + + for x, y in points: + await asyncio.wait_for(page.mouse.move(*home), timeout=POINT_TIMEOUT_S) + before = await page.evaluate("window.__moves") + try: + await asyncio.wait_for(page.mouse.move(x, y), timeout=POINT_TIMEOUT_S) + except asyncio.TimeoutError: + undelivered.append(((x, y), "hung -- the ack backstop did not fire")) + # The chain is wedged; nothing after this can run. + return undelivered + after = await asyncio.wait_for( + page.evaluate("window.__moves"), timeout=POINT_TIMEOUT_S) + if after == before: + undelivered.append(((x, y), "dispatched, but the page saw no mousemove")) + + # Prove the chain is not poisoned rather than trusting the absence of a + # timeout above. + try: + await asyncio.wait_for(page.mouse.move(*home), timeout=POINT_TIMEOUT_S) + await asyncio.wait_for(page.mouse.click(*home), timeout=POINT_TIMEOUT_S) + except asyncio.TimeoutError: + undelivered.append((home, "input dead after the sweep")) + return undelivered + + +async def main() -> int: + print("\n=== viewport boundary sweep ===") + failures = [] + for spoofed_os in SPOOFED_OSES: + for humanize in (False, True): + bad = await _sweep(spoofed_os, humanize) + for point, why in bad: + failures.append((spoofed_os, humanize, point, why)) + print(f" FAIL {point} {why}", flush=True) + if not bad: + print(" ok", flush=True) + + if failures: + print(f"\n {len(failures)} coordinate(s) did not reach the renderer.\n" + " Every one of these wedges the process-global activation chain on a\n" + " build without the ack backstop. Fix the conversion in\n" + " additions/juggler/input/MouseDispatch.js -- see docs/input-dispatch.md.\n") + return 1 + + print("\n PASS: every ring coordinate acked and observed, on every spoofed OS\n") + return 0 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main())) diff --git a/tests/patches/near-edge-mouse-deadlock.py b/tests/patches/near-edge-mouse-deadlock.py new file mode 100644 index 0000000..03d5ccd --- /dev/null +++ b/tests/patches/near-edge-mouse-deadlock.py @@ -0,0 +1,227 @@ +""" +Verify a mouse event on the viewport's top edge does not deadlock (daijro/camoufox#751, #752). + +Camoufox dispatches synthesized mouse events inside `activateAndRun()` +(additions/juggler/TargetRegistry.js), which serializes every dispatch on a +*process-global* promise chain. Each dispatch awaits a `hit-renderer` ack from +the content process. If an ack never arrives, the callback never returns, the +global chain never advances, and every later input event in the process hangs +behind it forever. Two triggers were already fixed and are guarded by +humanize-edge-deadlock.py (the far edges, x==width / y==height, #225) and +noop-mousemove-deadlock.py (a zero-displacement move). This is the third: the +*near* edge, y==0. + +MECHANISM +`sendOne()` in PageHandler.js dispatches at `eventY + boundingBox.top`, so a +relative y of 0 dispatches at absolute y == `boundingBox.top` exactly -- the +first row of the content area. That row is only partly covered whenever the +chrome above it is a fractional number of CSS pixels tall, and the widget rounds +the coordinate to a whole device row before hit-testing it. When +`round(top) < top` the rounded row still belongs to chrome, the event fires as +an exit event rather than eMouseMove, no ack is produced, and the chain wedges. + +That makes it a deterministic property of the chrome height, which Camoufox +varies with the spoofed OS. Measured on this build by logging `boundingBox` at +the dispatch site, and pairing each offset with the outcome of a single move: + + os boundingBox.top rounds to page.mouse.move(31, 0) + windows 51.4 51 (above) hangs, 5/5 + macos 53.1 53 (above) hangs, 5/5 + linux 56.5 57 (below) completes, 8/8 + +So the default randomized fingerprint reaches it on most launches, and a run +that spoofs Linux never does -- which is why this went unnoticed while the +far-edge guards were in place. Relative x == 0 is unaffected for the same +reason: `boundingBox.left` is a whole 0, so it needs no rounding. + +WHY NOT WIDEN THE BOUNDS CHECK +The far edges were fixed by treating them as out-of-viewport. 0 cannot be: it is +a legitimate in-viewport coordinate a caller may ask for, and the out-of-viewport +branch silently drops mousedown/mouseup, so widening the check would turn the +hang into a click that reports success and fires nothing (#752). The fix snaps +the dispatched coordinate to the first whole pixel inside the browser element, +which stays within content pixel 0 while landing clear of the boundary. + +COVERAGE +Both dispatch paths reach the same conversion, so both are covered: + * direct dispatch -- `page.mouse.move(x, 0)`, humanize off, and the humanized + move's explicit endpoint. Deterministic on an affected chrome offset. + * humanized trajectory -- how it is actually hit in the field. Every + PageHandler starts at `_lastTrackedPos = {x: 0, y: 0}` (PageHandler.js:86), + so a session's FIRST humanized move always departs from the top-left corner, + and with the +/-80px knot boundary from MouseTrajectories.hpp the curve rides + the y==0 row. On a stock build a first humanized click hung on 5 of 20 cold + pages; all five had dispatched a point at y==0 and the 15 that completed had + dispatched none. Sampled here rather than asserted deterministically, since + whether the curve touches the row is random. + +Run against a specific build: + CAMOUFOX_EXECUTABLE_PATH=/path/to/camoufox-bin python3 tests/patches/near-edge-mouse-deadlock.py + +What PASS means: + * a move onto the top edge completes on every spoofed OS, and the page + actually observes it -- an event swallowed by chrome leaves no mousemove; + * the browser still responds to input afterwards, proving the global chain + is not poisoned; + * a session's first humanized click completes on repeated cold pages. + +Before the fix the first direct move times out; after it, every move completes. +""" + +import asyncio +import os +import sys + +from camoufox.async_api import AsyncCamoufox + +# The chrome height, and so whether the top row rounds into chrome, depends on +# the spoofed OS. Cover all three rather than assuming which one this host's +# chrome puts on the wrong side of the boundary. +SPOOFED_OSES = ["windows", "macos", "linux"] +# Relative y == 0 is the deadlock coordinate. x is varied only to show it is the +# whole row that is poisoned, not one particular pixel. +TOP_EDGE_TARGETS = [(31, 0), (0, 0), (500, 0)] +# Fresh pages for the humanized half: each resets _lastTrackedPos to (0, 0), so +# each is an independent chance for the first trajectory to ride the top edge. +COLD_PAGES = 4 +# Close enough to the top that a trajectory from (0, 0) sweeps the y==0 row. +HUMANIZED_TARGET = (660, 186) +INTERIOR = (250, 250) +TIMEOUT_S = 20 + +EXECUTABLE_PATH = os.environ.get("CAMOUFOX_EXECUTABLE_PATH") + +RECORDER = "window.__moves=0;addEventListener('mousemove',()=>window.__moves++)" + + +def _launch_kwargs(humanize, spoofed_os): + kwargs = dict(headless=True, os=spoofed_os, humanize=humanize) + if EXECUTABLE_PATH: + kwargs["executable_path"] = EXECUTABLE_PATH + return kwargs + + +def _deadlock_report(what): + print( + f"\n DEADLOCK: {what} produced no hit-renderer ack. The global activation\n" + " chain is now wedged -- all further input hangs.\n" + " Fix: snap the dispatched coordinate to the first whole pixel inside the\n" + " browser element in PageHandler.js sendOne(), so a relative 0 does not\n" + " land on the fractional chrome/content boundary.\n" + ) + + +async def _direct_moves() -> bool: + """A plain move onto the top edge must complete and be seen by the page.""" + print("\n=== direct moves onto the top edge (humanize off) ===") + for spoofed_os in SPOOFED_OSES: + async with AsyncCamoufox(**_launch_kwargs(False, spoofed_os)) as browser: + page = await browser.new_page() + await page.set_content('') + await page.evaluate(RECORDER) + # Start from an interior point so the move under test is a real + # displacement, not a no-op skipped before dispatch. + await asyncio.wait_for(page.mouse.move(*INTERIOR), timeout=TIMEOUT_S) + + for x, y in TOP_EDGE_TARGETS: + label = f" [{spoofed_os}] move -> ({x}, {y})" + before = await page.evaluate("window.__moves") + try: + await asyncio.wait_for(page.mouse.move(x, y), timeout=TIMEOUT_S) + except asyncio.TimeoutError: + print(f"{label} FAIL: no ack after {TIMEOUT_S}s") + _deadlock_report(f"a mousemove at ({x}, {y})") + return False + after = await asyncio.wait_for( + page.evaluate("window.__moves"), timeout=TIMEOUT_S + ) + if after == before: + print(f"{label} FAIL: dispatched but the page saw no mousemove") + print( + "\n The event was delivered outside the content area. It did not\n" + " hang this time, but it never reached the renderer either.\n" + ) + return False + print(f"{label} ok") + await asyncio.wait_for(page.mouse.move(*INTERIOR), timeout=TIMEOUT_S) + + # The chain survived: prove input still works rather than trusting the + # absence of a timeout above. + try: + await asyncio.wait_for(page.mouse.move(400, 300), timeout=TIMEOUT_S) + live = await asyncio.wait_for( + page.evaluate("window.__moves"), timeout=TIMEOUT_S + ) + except asyncio.TimeoutError: + print(f" [{spoofed_os}] FAIL: unresponsive after the edge moves") + return False + if not live: + print(f" [{spoofed_os}] FAIL: no mousemove observed at all") + return False + return True + + +async def _humanized() -> bool: + """The humanize path reaches the same conversion, by endpoint and by curve.""" + print("\n=== humanized moves (humanize on) ===") + for spoofed_os in SPOOFED_OSES: + async with AsyncCamoufox(**_launch_kwargs(True, spoofed_os)) as browser: + # A humanized move whose destination IS the top edge: the trajectory's + # explicit endpoint dispatch is unconditional, so this is the + # deterministic half. + page = await browser.new_page() + await page.set_content('') + await page.evaluate(RECORDER) + await asyncio.wait_for(page.mouse.move(*INTERIOR), timeout=TIMEOUT_S) + label = f" [{spoofed_os}] humanized move -> (500, 0)" + try: + await asyncio.wait_for(page.mouse.move(500, 0), timeout=TIMEOUT_S) + except asyncio.TimeoutError: + print(f"{label} FAIL: no ack after {TIMEOUT_S}s") + _deadlock_report("a humanized move ending at y==0") + return False + print(f"{label} ok") + await page.close() + + # Cold pages: the trajectory departs (0, 0) and may ride the y==0 row. + for i in range(1, COLD_PAGES + 1): + label = f" [{spoofed_os}] cold page {i}/{COLD_PAGES}: click -> {HUMANIZED_TARGET}" + page = await browser.new_page() + await page.set_content( + '' + ) + await page.evaluate( + "window.__clicked=false;document.getElementById('b')" + ".addEventListener('click',()=>window.__clicked=true)" + ) + try: + # click() moves first, so this is the session's first + # trajectory -- generated from the initial (0, 0). + await asyncio.wait_for(page.click("#b"), timeout=TIMEOUT_S) + clicked = await asyncio.wait_for( + page.evaluate("window.__clicked"), timeout=TIMEOUT_S + ) + except asyncio.TimeoutError: + print(f"{label} FAIL: no ack after {TIMEOUT_S}s") + _deadlock_report("a humanized trajectory point at y==0") + return False + if not clicked: + print(f"{label} FAIL: click completed but the target never fired") + return False + print(f"{label} ok") + await page.close() + return True + + +async def main() -> int: + if not await _direct_moves(): + return 1 + if not await _humanized(): + return 1 + print("\n PASS: top-edge moves completed and were seen; input still live\n") + return 0 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main())) diff --git a/tests/patches/search-service-init.py b/tests/patches/search-service-init.py new file mode 100644 index 0000000..4d29a5a --- /dev/null +++ b/tests/patches/search-service-init.py @@ -0,0 +1,148 @@ +r""" +Verify the search service still initializes (no-search-engines.patch). + +Camoufox ships no search engines: no-search-engines.patch short-circuits +SearchEngineSelector.#getConfiguration() with a hardcoded stub instead of +fetching from Remote Settings, which is dead anyway because camoufox.cfg sets +services.settings.server to "". + +The stub's *shape* is load-bearing, and that is what this guards. The selector +is the Rust SearchEngineSelector, which deserializes search-config **v2**: + + #[serde(tag = "recordType", rename_all = "camelCase")] + enum JSONSearchConfigurationRecords { ... } + +`recordType` is the enum's tag, so a record without it aborts the entire +document with `missing field \`recordType\``. The stub used to be a v1 record +(`appliesTo`/`webExtension`), so setSearchConfig() threw on every single +launch, #init() died, and the browser ran with no search service at all -- +which also takes out the urlbar's heuristic result, so history and autofill +never render. See daijro/camoufox#737. + +That shipped broken in beta.28, .29 and .30 without anything noticing, because +the failure is a console error on a browser that otherwise starts fine. Hence +this test. + +Note that "no engines" cannot be expressed as an empty configuration: +getEngineConfiguration() rejects `[]` with "Failed to get engine data from +Remote Settings", and SearchSettings refuses to write without an engine. So the +stub carries one inert engine, and this test asserts both halves -- that init +completes, AND that the only engine present is that inert one. + +Run from any venv (no playwright needed -- this drives the binary directly): + python tests/patches/search-service-init.py + python tests/patches/search-service-init.py --binary /path/to/camoufox-bin +""" + +import os +import re +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import List, Optional + +REPO_ROOT = Path(__file__).resolve().parents[2] + +# Long enough for the search service to init and write settings; the browser +# does not exit on its own with -headless about:blank, so it is killed after. +LAUNCH_TIMEOUT_S = 60 + +# Any of these appearing as an added engine means the "no search engines" +# stance has been lost. +REAL_ENGINES = ("Google", "Bing", "DuckDuckGo", "Perplexity", "Wikipedia", + "Yahoo", "Ecosia", "Qwant", "Baidu", "Yandex") + +ADDED_ENGINE_RE = re.compile(r'"#addEngineToStore: Adding engine:" "([^"]*)"') + + +def resolve_binary(argv: List[str]) -> Optional[Path]: + if "--binary" in argv: + return Path(argv[argv.index("--binary") + 1]).resolve() + if os.environ.get("CAMOUFOX_BINARY"): + return Path(os.environ["CAMOUFOX_BINARY"]).resolve() + matches = sorted(REPO_ROOT.glob("camoufox-*/obj-*/dist/bin/camoufox-bin")) + return matches[-1] if matches else None + + +def launch_and_capture(binary: Path, profile: Path) -> str: + """Start the browser on a fresh profile with search logging, return output.""" + (profile / "user.js").write_text('user_pref("browser.search.log", true);\n') + proc = subprocess.Popen( + [str(binary), "-profile", str(profile), "-headless", "-no-remote", "about:blank"], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, errors="replace", + ) + try: + out, _ = proc.communicate(timeout=LAUNCH_TIMEOUT_S) + return out + except subprocess.TimeoutExpired: + # Expected: about:blank never exits. Take what was logged. + proc.kill() + out, _ = proc.communicate() + return out + + +def main() -> int: + binary = resolve_binary(sys.argv) + if binary is None or not binary.exists(): + print(f"FATAL: no camoufox binary found (looked for {binary})") + return 1 + + print(f"Binary: {binary}") + with tempfile.TemporaryDirectory(prefix="camoufox-search-") as tmp: + profile = Path(tmp) + log = launch_and_capture(binary, profile) + settings_written = (profile / "search.json.mozlz4").exists() + settings_blob = ( + (profile / "search.json.mozlz4").read_bytes() if settings_written else b"" + ) + + failures = [] + + # 1. The deserialization error this patch has historically caused. + record_type_errors = log.count("missing field `recordType`") + if record_type_errors: + failures.append( + f"the config stub failed to deserialize: {record_type_errors} " + "'missing field `recordType`' error(s) -- the stub is not " + "search-config v2 shaped" + ) + + # 2. init() has to actually finish. + if "Completed #init" not in log: + failures.append("SearchService never logged 'Completed #init'") + for line in log.splitlines(): + if "#init: failure initializing search" in line: + failures.append(f"SearchService reported init failure: {line.strip()[:160]}") + break + + # 3. Settings must reach disk -- SearchSettings refuses to write with no engine. + if not settings_written: + failures.append("search.json.mozlz4 was never written") + + # 4. ...and the stance must still hold: nothing but the inert engine. + engines = sorted(set(ADDED_ENGINE_RE.findall(log))) + leaked = [e for e in engines if any(r.lower() in e.lower() for r in REAL_ENGINES)] + if leaked: + failures.append(f"real search engines were added: {', '.join(leaked)}") + if not engines: + failures.append("no engine was added at all (settings cannot be written)") + + print(f" recordType errors : {record_type_errors}") + print(f" 'Completed #init' : {'Completed #init' in log}") + print(f" search.json.mozlz4 : {'written' if settings_written else 'MISSING'}" + f"{f' ({len(settings_blob)} bytes)' if settings_written else ''}") + shown = ', '.join(f'"{e}"' for e in engines) if engines else "(none)" + print(f" engines added : {shown}") + + print() + if failures: + for f in failures: + print(f"FAIL: {f}") + return 1 + print("PASS: the search service initializes, and the only engine is the inert stub.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/patches/touchscreen-digitizer.py b/tests/patches/touchscreen-digitizer.py new file mode 100644 index 0000000..a2195ab --- /dev/null +++ b/tests/patches/touchscreen-digitizer.py @@ -0,0 +1,234 @@ +""" +Verify the touchscreen digitizer spoof (touchscreen-fingerprint-spoofing.patch +plus the any-pointer half of force-default-pointer.patch). + +Setting `navigator.maxTouchPoints` above zero has to produce the fingerprint of +a touchscreen *laptop*, not of a phone. Three things must move together: + + navigator.maxTouchPoints the digitizer is reported (Navigator.cpp) + (any-pointer: coarse) it joins the any- pointer set (nsMediaFeatures.cpp) + window.TouchEvent/Touch the touch interfaces appear (TouchEvent.cpp) + +and three things must deliberately NOT move: + + (pointer: coarse) stays false -- the trackpad is still primary + (hover: hover) stays true -- so does hovering + 'ontouchstart' in window stays false -- legacy_apis is off on desktop + +The last one is the subtle one. `ontouchstart` is gated by LegacyAPIEnabled, +not PrefEnabled, and dom.w3c_touch_events.legacy_apis.enabled defaults to +false everywhere but Android. A real Windows touchscreen laptop therefore +exposes TouchEvent while `'ontouchstart' in window` is false, and a build that +turns on "touch support" wholesale is *more* detectable than one that does +nothing. + +Run from any venv that has playwright: + python tests/patches/touchscreen-digitizer.py + python tests/patches/touchscreen-digitizer.py --binary /path/to/camoufox-bin + +Which binary is tested, in order of precedence: + --binary | $CAMOUFOX_BINARY | the in-tree obj-*/dist/bin/camoufox-bin +""" + +import asyncio +import json +import os +import sys +from pathlib import Path +from typing import Any, Dict, Optional + +REPO_ROOT = Path(__file__).resolve().parents[2] + +# The digitizer count the reference was recorded with. +SPOOFED_TOUCH_POINTS = 5 + +# --------------------------------------------------------------------------- +# The recorded reference: a Dell XPS 15 9510 (Windows 10, touchscreen) running +# Firefox 152.0, captured with tests/assets/touch-reference.html. +# +# UA Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) +# Gecko/20100101 Firefox/152.0 +# platform Win32 screen 1382x864 @ dpr 2.5 +# +# These are the sixteen static touch signals from that capture, verbatim. The +# recording also carries 284 input events and eight context values (screen, +# dpr, hardwareConcurrency, ...) which are not touch signals and are not +# asserted here. +# +# Two of these are easy to get wrong from first principles, so note them: +# +# createTouch is FALSE. document.createTouch is a legacy API gated by +# TouchEvent::LegacyAPIEnabled, the same gate as ontouchstart, and +# dom.w3c_touch_events.legacy_apis.enabled is false off Android. A real +# touchscreen laptop exposes TouchEvent while createTouch stays absent. +# PointerEvent is TRUE, and unconditionally so -- it is not gated on a +# digitizer at all. It is listed to pin that it must not start varying. +# --------------------------------------------------------------------------- +RECORDED: Dict[str, Any] = { + # --- touch API surface --- + "navigator.maxTouchPoints": SPOOFED_TOUCH_POINTS, + "'ontouchstart' in window": False, + "window.TouchEvent": True, + "window.Touch": True, + "document.createTouch": False, + "window.PointerEvent": True, + # --- CSS pointer/hover media queries --- + "(pointer: coarse)": False, + "(pointer: fine)": True, + "(pointer: none)": False, + "(any-pointer: coarse)": True, + "(any-pointer: fine)": True, + "(any-pointer: none)": False, + "(hover: hover)": True, + "(hover: none)": False, + "(any-hover: hover)": True, + "(any-hover: none)": False, +} + +# Collected and printed, never asserted on -- none of these is in the recording, +# so there is no measured value to hold them to: +# +# window.TouchList shares TouchEvent's gate, so it tracks TouchEvent and +# flips with it. Worth seeing, but it would just restate that assertion. +# document.createEvent('TouchEvent') gated by LegacyAPIEnabled, like +# createTouch, so it is false on desktop. +# 'ontouchstart' on document / documentElement the same mixin as the window +# one, printed to show all three agree. +INFORMATIONAL = ( + "window.TouchList", + "document.createEvent('TouchEvent')", + "'ontouchstart' in document", + "'ontouchstart' in documentElement", +) + +# maxTouchPoints=0 must look exactly like a machine with no digitizer, or the +# patch has leaked touch capability into every ordinary launch. +NO_DIGITIZER: Dict[str, Any] = { + "(pointer: fine)": True, + "(pointer: coarse)": False, + "(any-pointer: fine)": True, + "(any-pointer: coarse)": False, + "(hover: hover)": True, + "(any-hover: hover)": True, + "navigator.maxTouchPoints": 0, + "window.TouchEvent": False, + "window.Touch": False, + "'ontouchstart' in window": False, +} + +PROBE_JS = r"""() => { + const mq = q => window.matchMedia(q).matches; + let createEvent = false; + try { createEvent = !!document.createEvent('TouchEvent'); } catch (e) { createEvent = false; } + let createTouch = false; + try { createTouch = typeof document.createTouch === 'function'; } catch (e) { createTouch = false; } + return { + "(pointer: fine)": mq("(pointer: fine)"), + "(pointer: coarse)": mq("(pointer: coarse)"), + "(pointer: none)": mq("(pointer: none)"), + "(any-pointer: fine)": mq("(any-pointer: fine)"), + "(any-pointer: coarse)": mq("(any-pointer: coarse)"), + "(any-pointer: none)": mq("(any-pointer: none)"), + "(hover: hover)": mq("(hover: hover)"), + "(hover: none)": mq("(hover: none)"), + "(any-hover: hover)": mq("(any-hover: hover)"), + "(any-hover: none)": mq("(any-hover: none)"), + "navigator.maxTouchPoints": navigator.maxTouchPoints, + "window.TouchEvent": "TouchEvent" in window, + "window.Touch": "Touch" in window, + "document.createTouch": createTouch, + "window.PointerEvent": "PointerEvent" in window, + "'ontouchstart' in window": "ontouchstart" in window, + "'ontouchstart' in document": "ontouchstart" in document, + "'ontouchstart' in documentElement": "ontouchstart" in document.documentElement, + "window.TouchList": "TouchList" in window, + "document.createEvent('TouchEvent')": createEvent + }; +}""" + + +def resolve_binary(argv) -> Optional[Path]: + if "--binary" in argv: + return Path(argv[argv.index("--binary") + 1]).resolve() + if os.environ.get("CAMOUFOX_BINARY"): + return Path(os.environ["CAMOUFOX_BINARY"]).resolve() + matches = sorted(REPO_ROOT.glob("camoufox-*/obj-*/dist/bin/camoufox-bin")) + return matches[-1] if matches else None + + +async def probe(binary: Path, max_touch_points: Optional[int]) -> Dict[str, Any]: + """Launch the binary with a config and read every touch signal back.""" + from playwright.async_api import async_playwright + + config: Dict[str, Any] = {} + if max_touch_points is not None: + config["navigator.maxTouchPoints"] = max_touch_points + + env = dict(os.environ) + env["CAMOU_CONFIG_1"] = json.dumps(config) + + async with async_playwright() as p: + browser = await p.firefox.launch( + executable_path=str(binary), headless=True, env=env + ) + try: + page = await browser.new_page() + await page.goto("about:blank") + return await page.evaluate(PROBE_JS) + finally: + await browser.close() + + +def compare(actual: Dict[str, Any], expected: Dict[str, Any]) -> bool: + """Print a per-signal table. True only if every expected signal matches.""" + missing = sorted(set(expected) - set(actual)) + if missing: + print(f" FAIL: probe never collected: {', '.join(missing)}") + return False + + width = max(len(k) for k in expected) + failures = 0 + for name, want in expected.items(): + got = actual[name] + ok = want == got + failures += not ok + mark = "ok " if ok else "FAIL" + detail = f"{str(got):<7}" if ok else f"{str(got):<7} (expected {want})" + print(f" [{mark}] {name:<{width}} {detail}") + + print(f"\n {len(expected) - failures}/{len(expected)} signals match") + return failures == 0 + + +async def main() -> int: + binary = resolve_binary(sys.argv) + if binary is None or not binary.exists(): + print(f"FATAL: no camoufox binary found (looked for {binary})") + return 1 + + print(f"Binary: {binary}") + + print(f"\n=== navigator.maxTouchPoints = {SPOOFED_TOUCH_POINTS} " + f"(vs recorded reference) ===") + spoofed = await probe(binary, SPOOFED_TOUCH_POINTS) + matched = compare(spoofed, RECORDED) + for name in INFORMATIONAL: + print(f" [info] {name} = {spoofed[name]}") + + print("\n=== navigator.maxTouchPoints = 0 (control) ===") + control_ok = compare(await probe(binary, 0), NO_DIGITIZER) + + print() + if matched and control_ok: + print("PASS: the spoofed fingerprint matches the recording, and " + "maxTouchPoints=0 is untouched.") + return 0 + if not matched: + print("FAIL: the spoofed fingerprint does not match the recording.") + if not control_ok: + print("FAIL: maxTouchPoints=0 no longer looks like a machine without a digitizer.") + return 1 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main()))