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

1. Static signals — captured automatically

+
reading…
+

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

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

3. Paste this back

+ + diff --git a/tests/patches/touchscreen-digitizer.py b/tests/patches/touchscreen-digitizer.py index 80330be..a2195ab 100644 --- a/tests/patches/touchscreen-digitizer.py +++ b/tests/patches/touchscreen-digitizer.py @@ -43,63 +43,63 @@ REPO_ROOT = Path(__file__).resolve().parents[2] SPOOFED_TOUCH_POINTS = 5 # --------------------------------------------------------------------------- -# The recorded reference: a Dell XPS 15 9510 (Windows, touchscreen) on Firefox -# 152.0, launched with {"navigator.maxTouchPoints": 5}. +# The recorded reference: a Dell XPS 15 9510 (Windows 10, touchscreen) running +# Firefox 152.0, captured with tests/assets/touch-reference.html. # -# PROVENANCE -- READ BEFORE TRUSTING A PASS. +# 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 values were NOT captured from the reference machine. The original -# recording is on the desktop of the i9 reference Windows box and was not -# reachable from the build host, so they were reconstructed on 2026-09-05 from: +# 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. # -# [spec] stated directly in the task description -# [derived] fixed by Gecko's own gating logic in the 152.0 source, which -# determines what a real Windows touchscreen laptop must report: -# - dom.w3c_touch_events.enabled defaults to 2 (autodetect) on -# non-Mac desktop, so a machine with a digitizer resolves it -# true and exposes TouchEvent/Touch. -# - dom.w3c_touch_events.legacy_apis.enabled defaults to -# @IS_ANDROID@, i.e. false on Windows, so the ontouchstart -# handler attributes stay off the interfaces. That is what -# makes TouchEvent=true alongside ontouchstart=false a -# coherent desktop shape rather than a contradiction. -# - GetPointerCapabilities is asked separately for the primary -# and the any- pointer, so the digitizer lands only in the -# any- set while the trackpad keeps the primary one fine. +# Two of these are easy to get wrong from first principles, so note them: # -# When the Windows box is reachable, check the real capture against this table -# and correct any disagreement here -- the capture wins, not this table. +# 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: fine)": True, # spec - "(pointer: coarse)": False, # spec - "(pointer: none)": False, # derived - "(any-pointer: fine)": True, # derived - "(any-pointer: coarse)": True, # spec - "(any-pointer: none)": False, # derived - "(hover: hover)": True, # spec - "(hover: none)": False, # derived - "(any-hover: hover)": True, # spec - "(any-hover: none)": False, # derived - # --- Touch API surface --- - "navigator.maxTouchPoints": SPOOFED_TOUCH_POINTS, # derived (echoes config) - "window.TouchEvent": True, # spec - "window.Touch": True, # spec - "'ontouchstart' in window": False, # spec - "'ontouchstart' in document": False, # derived - "'ontouchstart' in documentElement": False, # derived + "(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. Both are consistent between a real -# touchscreen laptop and this build, but neither belongs in the recorded set: +# 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 a real touchscreen laptop -# reports true -- but it is false on an unfixed build, which would make it -# a FOURTH failing signal when the task states there are exactly three. -# document.createEvent('TouchEvent') gated by LegacyAPIEnabled, so it throws -# on desktop Windows and is false on both sides. -INFORMATIONAL = ("window.TouchList", "document.createEvent('TouchEvent')") +# 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. @@ -120,6 +120,8 @@ 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)"), @@ -134,6 +136,8 @@ PROBE_JS = r"""() => { "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,