mirror of
https://github.com/daijro/camoufox.git
synced 2026-09-08 00:00:43 +00:00
Merge pull request #756 from JWriter20/integration/triage-2026-09-05
Integration branch: eleven open PRs built and verified together on FF152
This commit is contained in:
@@ -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
|
||||
@@ -7,6 +7,7 @@ Written by daijro.
|
||||
#include "json.hpp"
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <tuple>
|
||||
#include <optional>
|
||||
#include <codecvt>
|
||||
@@ -116,6 +117,31 @@ inline std::vector<std::string> 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<std::string>& FontAllowlist() {
|
||||
static const std::vector<std::string> 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 <typename T>
|
||||
inline std::optional<T> GetUintImpl(const std::string& key) {
|
||||
const auto& data = GetJson();
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 */);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
+166
-36
@@ -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<FontPrefs>();
|
||||
|
||||
+ // Hijack the kFontSystemWhitelistPref pref
|
||||
+ if (std::vector<std::string> 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<std::string> 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"],
|
||||
)
|
||||
|
||||
@@ -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<unsigned char>(a)) ==
|
||||
+ std::tolower(static_cast<unsigned char>(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 <algorithm>
|
||||
#include <limits>
|
||||
|
||||
@@ -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 "<family>:Linux" / "<family>:Mac"
|
||||
+ // / "<family>: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 "<family>:Linux" / "<family>:Mac" /
|
||||
+ // "<family>: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<unsigned char>(a)) ==
|
||||
+ std::tolower(static_cast<unsigned char>(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;
|
||||
|
||||
@@ -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<PointerCapabilities>(intValue);
|
||||
+ return PointerCapabilities::Fine | PointerCapabilities::Hover;
|
||||
+ return capabilities;
|
||||
+#endif
|
||||
}
|
||||
|
||||
PointerCapabilities Gecko_MediaFeatures_PrimaryPointerCapabilities(
|
||||
const Document* aDocument) {
|
||||
return GetPointerCapabilities(aDocument,
|
||||
LookAndFeel::IntID::PrimaryPointerCapabilities);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
From: Jenkins <jenkins@magrathea>
|
||||
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() {
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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<PDMFactory> 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<PDMFactory> 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"]
|
||||
@@ -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<object[]>}
|
||||
* 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",
|
||||
});
|
||||
|
||||
@@ -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`.
|
||||
@@ -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<nsCString>& 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;
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
|
||||
@@ -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<uint32_t>& 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"]
|
||||
@@ -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}")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 (<OS tokens>)": 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']
|
||||
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
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.
|
||||
|
||||
@@ -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 == []
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
@@ -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 (<OS token>)", 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
|
||||
Executable
+105
@@ -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())
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<!doctype html><meta charset="utf-8"><title>Camoufox touch reference</title>
|
||||
<style>
|
||||
body{font:14px system-ui;margin:0;padding:16px;background:#0f1310;color:#e8ebe2}
|
||||
h2{font-size:15px;margin:18px 0 8px}
|
||||
#pad{height:230px;border:2px dashed #5fb5ac;border-radius:10px;display:flex;
|
||||
align-items:center;justify-content:center;color:#5fb5ac;font-size:17px;
|
||||
touch-action:none;user-select:none;margin-bottom:12px}
|
||||
textarea{width:100%;height:260px;background:#1b1f18;color:#cfe;border:1px solid #2e342a;
|
||||
border-radius:8px;font:11px ui-monospace,monospace;padding:10px}
|
||||
button{font:600 14px system-ui;padding:9px 16px;border-radius:7px;border:1px solid #5fb5ac;
|
||||
background:#5fb5ac;color:#0f1310;cursor:pointer;margin-right:8px}
|
||||
.n{color:#98a090;font-size:12px}
|
||||
</style>
|
||||
<h2>1. Static signals — captured automatically</h2>
|
||||
<div class="n" id="stat">reading…</div>
|
||||
<h2>2. Touch capture — please do these three, in order, inside the box</h2>
|
||||
<div class="n">a) one-finger tap · b) two-finger tap · c) one-finger drag across the box</div>
|
||||
<div id="pad">touch here</div>
|
||||
<button id="copy">Copy everything</button><button id="reset">Clear touches</button>
|
||||
<span class="n" id="count">0 events</span>
|
||||
<h2>3. Paste this back</h2>
|
||||
<textarea id="out" readonly></textarea>
|
||||
<script>
|
||||
function mq(q){return matchMedia(q).matches}
|
||||
var stat={
|
||||
ua:navigator.userAgent, platform:navigator.platform, ffVersion:navigator.userAgent.match(/Firefox\/([\d.]+)/)?.[1],
|
||||
maxTouchPoints:navigator.maxTouchPoints, ontouchstart:('ontouchstart' in window),
|
||||
TouchEvent:typeof window.TouchEvent!=='undefined', Touch:typeof window.Touch!=='undefined',
|
||||
createTouch:typeof document.createTouch==='function',
|
||||
PointerEvent:typeof window.PointerEvent!=='undefined',
|
||||
pointer_coarse:mq('(pointer: coarse)'), pointer_fine:mq('(pointer: fine)'), pointer_none:mq('(pointer: none)'),
|
||||
anyPointer_coarse:mq('(any-pointer: coarse)'), anyPointer_fine:mq('(any-pointer: fine)'), anyPointer_none:mq('(any-pointer: none)'),
|
||||
hover_hover:mq('(hover: hover)'), hover_none:mq('(hover: none)'),
|
||||
anyHover_hover:mq('(any-hover: hover)'), anyHover_none:mq('(any-hover: none)'),
|
||||
screen:screen.width+'x'+screen.height, avail:screen.availWidth+'x'+screen.availHeight,
|
||||
dpr:devicePixelRatio, colorDepth:screen.colorDepth, hardwareConcurrency:navigator.hardwareConcurrency
|
||||
};
|
||||
document.getElementById('stat').textContent=JSON.stringify(stat,null,1);
|
||||
var log=[],t0=null;
|
||||
function rec(e){
|
||||
if(t0===null)t0=performance.now();
|
||||
var r={t:+(performance.now()-t0).toFixed(1),type:e.type};
|
||||
if(e instanceof PointerEvent){
|
||||
r.pointerType=e.pointerType; r.isPrimary=e.isPrimary; r.pressure=e.pressure;
|
||||
r.width=e.width; r.height=e.height; r.tiltX=e.tiltX; r.tiltY=e.tiltY; r.twist=e.twist;
|
||||
r.pointerId=e.pointerId; r.buttons=e.buttons;
|
||||
}
|
||||
if(window.TouchEvent&&e instanceof TouchEvent){
|
||||
r.touches=e.touches.length; r.changed=e.changedTouches.length;
|
||||
r.pts=[].map.call(e.changedTouches,function(t){return {
|
||||
id:t.identifier,radiusX:t.radiusX,radiusY:t.radiusY,force:t.force,rotationAngle:t.rotationAngle};});
|
||||
}
|
||||
if(e.type.indexOf('mouse')===0){ r.buttons=e.buttons; r.detail=e.detail; }
|
||||
log.push(r); render();
|
||||
}
|
||||
['pointerdown','pointermove','pointerup','pointercancel',
|
||||
'touchstart','touchmove','touchend','touchcancel',
|
||||
'mousedown','mousemove','mouseup','click'].forEach(function(t){
|
||||
document.getElementById('pad').addEventListener(t,rec,{passive:true});
|
||||
});
|
||||
function render(){
|
||||
document.getElementById('count').textContent=log.length+' events';
|
||||
document.getElementById('out').value=JSON.stringify({static:stat,events:log},null,1);
|
||||
}
|
||||
render();
|
||||
document.getElementById('copy').onclick=function(){
|
||||
var o=document.getElementById('out'); o.select(); document.execCommand('copy');
|
||||
this.textContent='Copied';
|
||||
};
|
||||
document.getElementById('reset').onclick=function(){log=[];t0=null;render();};
|
||||
</script>
|
||||
@@ -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('<body style="margin:0;height:1200px"></body>')
|
||||
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()<end);}})()"))
|
||||
await asyncio.sleep(0.25)
|
||||
|
||||
print(f" content main thread blocked for {BLOCK_MS}ms; dispatching a move",
|
||||
flush=True)
|
||||
t0 = time.time()
|
||||
try:
|
||||
await asyncio.wait_for(page.mouse.move(500, 400), timeout=BOUNDED_S)
|
||||
except asyncio.TimeoutError:
|
||||
print(f" FAIL: still waiting after {BOUNDED_S}s")
|
||||
print(
|
||||
"\n The ack wait is unbounded. An event that never reaches the\n"
|
||||
" renderer will wedge the process-global activation chain forever.\n"
|
||||
" Fix: bound it in MouseDispatch.sendAcked() via\n"
|
||||
" EventWatcher.ensureEventWithin(). See docs/input-dispatch.md.\n")
|
||||
blocker.cancel()
|
||||
return 1
|
||||
waited = time.time() - t0
|
||||
print(f" returned after {waited:.1f}s (block runs for {BLOCK_MS / 1000:.0f}s)",
|
||||
flush=True)
|
||||
|
||||
# Let the block finish, then prove the chain advanced rather than died.
|
||||
try:
|
||||
await asyncio.wait_for(blocker, timeout=BLOCK_MS / 1000 + 15)
|
||||
except asyncio.TimeoutError:
|
||||
print(" FAIL: the blocking script never finished")
|
||||
return 1
|
||||
except Exception:
|
||||
pass # a lost evaluate is fine; the chain is what matters
|
||||
|
||||
before = await asyncio.wait_for(
|
||||
page.evaluate("window.__moves"), timeout=RECOVERY_TIMEOUT_S)
|
||||
try:
|
||||
await asyncio.wait_for(page.mouse.move(700, 500), timeout=RECOVERY_TIMEOUT_S)
|
||||
await asyncio.wait_for(page.mouse.click(650, 450), timeout=RECOVERY_TIMEOUT_S)
|
||||
after = await asyncio.wait_for(
|
||||
page.evaluate("window.__moves"), timeout=RECOVERY_TIMEOUT_S)
|
||||
except asyncio.TimeoutError:
|
||||
print(" FAIL: input dead after the late ack -- the chain is poisoned")
|
||||
return 1
|
||||
if after <= before:
|
||||
print(" FAIL: the browser accepted moves but the page saw none")
|
||||
return 1
|
||||
|
||||
print(f" input still live afterwards ({after - before} mousemove seen)")
|
||||
print("\n PASS: the ack wait is bounded and the chain recovers\n")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(asyncio.run(main()))
|
||||
@@ -0,0 +1,135 @@
|
||||
"""
|
||||
Sweep the viewport boundary for coordinates that never reach the renderer.
|
||||
|
||||
This replaces hand-picked edge targets, which are what let four deadlocks
|
||||
through. Each of the earlier tests probed the coordinates that had just been
|
||||
found broken, so the next variant was always outside the list:
|
||||
|
||||
humanize-edge-deadlock.py probes the right and bottom edges only, so the
|
||||
near edge (#751) passed it cleanly
|
||||
humanize-mouse-trajectory.py pins os="linux", the one spoofed OS whose
|
||||
chrome offset is immune to #751
|
||||
|
||||
The failure they all share: a synthesized event that hit-tests outside the
|
||||
content widget fires as an exit event rather than eMouseMove, no
|
||||
juggler-mouse-event-hit-renderer ack is produced, and -- because dispatch is
|
||||
serialized on activateAndRun()'s process-global chain -- that one missing ack
|
||||
wedges every later input event in the process. See docs/input-dispatch.md.
|
||||
|
||||
Rather than guess which coordinates do that, sweep the ring:
|
||||
|
||||
x in {0, 1, w/2, w-2, w-1} x y in {0, 1, h/2, h-2, h-1}
|
||||
|
||||
across every spoofed OS (the chrome offset that decides #751 is a function of
|
||||
it: windows 51.4, macos 53.1, linux 56.5) and with humanize both off and on.
|
||||
|
||||
Each point must be *delivered*, not merely survived: the page has to observe a
|
||||
mousemove. A point that is dropped without hanging is still a bug -- it is the
|
||||
"click did nothing" half of #752 -- and asserting only "did not time out" would
|
||||
miss it.
|
||||
|
||||
This test depends on the ack backstop (MouseDispatch kAckDeadlineMs) to run at
|
||||
all. Without it the first undelivered coordinate wedges the browser and the
|
||||
sweep dies there, reporting one point instead of the whole failing set.
|
||||
|
||||
Run against a specific build:
|
||||
CAMOUFOX_EXECUTABLE_PATH=/path/to/camoufox-bin python3 tests/patches/mouse-boundary-sweep.py
|
||||
|
||||
What PASS means: every ring coordinate, on every spoofed OS, humanized and not,
|
||||
was acked and seen by the page; and input is still live at the end of each run.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
|
||||
from camoufox.async_api import AsyncCamoufox
|
||||
|
||||
SPOOFED_OSES = ["windows", "macos", "linux"]
|
||||
# Well inside the viewport, so every dispatched point is a real displacement.
|
||||
INTERIOR = (0.34, 0.34)
|
||||
# Per-point ceiling. Comfortably above the 5s ack deadline, so a dropped point
|
||||
# shows up as "not delivered" with a fast return rather than as a timeout here.
|
||||
POINT_TIMEOUT_S = 25
|
||||
|
||||
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 _ring(w, h):
|
||||
xs = [0, 1, w // 2, w - 2, w - 1]
|
||||
ys = [0, 1, h // 2, h - 2, h - 1]
|
||||
return [(x, y) for y in ys for x in xs]
|
||||
|
||||
|
||||
async def _sweep(spoofed_os, humanize) -> 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('<body style="margin:0;height:1600px"></body>')
|
||||
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()))
|
||||
@@ -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('<body style="margin:0;height:1200px"></body>')
|
||||
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('<body style="margin:0;height:1200px"></body>')
|
||||
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(
|
||||
'<button id="b" style="position:absolute;left:600px;top:160px;'
|
||||
'width:120px;height:52px">go</button>'
|
||||
)
|
||||
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()))
|
||||
@@ -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())
|
||||
@@ -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 <path> | $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()))
|
||||
Reference in New Issue
Block a user