From 7f0e52e7920725115e9e90197c38afa42269a188 Mon Sep 17 00:00:00 2001 From: coffeegrind123 <103264516+coffeegrind123@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:10:00 +0300 Subject: [PATCH 01/12] Install the pinned fpgen model before fpgen is imported pythonlib left the model to fpgen, whose first import downloads the first release the GitHub API lists: model-4/2025, whose WebGL records have no vendor or renderer. Every generated launch on a fresh install failed with KeyError: 'vendor'. scripts/pin-fpgen-model.py pinned the model for CI only, and fpgen's five-week refresh replaced even that pin, under a stamp the script's --check still trusted. fpgen is now imported only through fpgen_model.load_fpgen(), which installs the release named by the pin, checks the archive and each file against their sha256, and dates the files past fpgen's refresh. It writes the layout and stamp the script and the TypeScript launcher use, verifies an install by hashing it, and leaves FPGEN_MODEL_URL to fpgen. `camoufox fetch` installs the model as well. The pin gains each file's sha256; the package carries a copy of it, and the script now calls the module. Co-Authored-By: Claude Opus 5.5 (1M context) (cherry picked from commit f043de359878571278b7f2e50374e94279eb809b) --- pythonlib/README.md | 2 + pythonlib/camoufox/__main__.py | 2 + pythonlib/camoufox/exceptions.py | 6 + pythonlib/camoufox/fingerprints.py | 4 +- pythonlib/camoufox/fpgen-model.json | 19 ++ pythonlib/camoufox/fpgen_model.py | 168 ++++++++++++++ pythonlib/camoufox/webgl.py | 9 +- pythonlib/tests/test_fpgen_model.py | 336 ++++++++++++++++++++++++++++ scripts/data/fpgen-model.json | 7 +- scripts/pin-fpgen-model.py | 138 ++---------- typescript/src/fpgen/pin.ts | 12 +- 11 files changed, 569 insertions(+), 134 deletions(-) create mode 100644 pythonlib/camoufox/fpgen-model.json create mode 100644 pythonlib/camoufox/fpgen_model.py create mode 100644 pythonlib/tests/test_fpgen_model.py diff --git a/pythonlib/README.md b/pythonlib/README.md index 0659c4f03..8871e2cda 100644 --- a/pythonlib/README.md +++ b/pythonlib/README.md @@ -37,6 +37,8 @@ Next, download the Camoufox browser: camoufox fetch ``` +`fetch` also installs fpgen's model, pinned by sha256, into fpgen's package directory. Otherwise the first generated fingerprint installs it. Run `fetch` as that directory's owner if the browser will run as another user, e.g. while building a Docker image. + To uninstall, run `camoufox remove`. --- diff --git a/pythonlib/camoufox/__main__.py b/pythonlib/camoufox/__main__.py index de1dd11c7..81e44ff71 100644 --- a/pythonlib/camoufox/__main__.py +++ b/pythonlib/camoufox/__main__.py @@ -11,6 +11,7 @@ import rich_click as click from .addons import DefaultAddons, maybe_download_addons +from .fpgen_model import ensure_fpgen_model from .geolocation import ( ALLOW_GEOIP, GEOIP_DIR, @@ -323,6 +324,7 @@ def fetch(version): if ALLOW_GEOIP: download_mmdb() maybe_download_addons(list(DefaultAddons)) + ensure_fpgen_model() def _set_channel(repo_name: str, channel_type: str): diff --git a/pythonlib/camoufox/exceptions.py b/pythonlib/camoufox/exceptions.py index 292e992e6..9be0e780d 100644 --- a/pythonlib/camoufox/exceptions.py +++ b/pythonlib/camoufox/exceptions.py @@ -184,3 +184,9 @@ class ProfileDirectoryError(RuntimeError): """Raised when Camoufox's required runtime directory cannot be prepared.""" ... + + +class FpgenModelError(RuntimeError): + """Raised when fpgen's pinned model cannot be installed where fpgen reads it.""" + + ... diff --git a/pythonlib/camoufox/fingerprints.py b/pythonlib/camoufox/fingerprints.py index b2b4f891a..ae6fb92ac 100644 --- a/pythonlib/camoufox/fingerprints.py +++ b/pythonlib/camoufox/fingerprints.py @@ -29,9 +29,9 @@ def _generator(): global _FP_GENERATOR if _FP_GENERATOR is None: - from fpgen import Generator + from .fpgen_model import load_fpgen - _FP_GENERATOR = Generator() + _FP_GENERATOR = load_fpgen().Generator() return _FP_GENERATOR diff --git a/pythonlib/camoufox/fpgen-model.json b/pythonlib/camoufox/fpgen-model.json new file mode 100644 index 000000000..7016ee117 --- /dev/null +++ b/pythonlib/camoufox/fpgen-model.json @@ -0,0 +1,19 @@ +{ + "tag": "model-2/2026", + "asset": "model-release.zip", + "size": 1564571, + "sha256": "6530b8322cdaa4ec042921c8d9a0369a0e6e0269ba636c01a7203e4a2f109936", + "url": "https://github.com/scrapfly/fingerprint-generator/releases/download/model-2/2026/model-release.zip", + "repo": "scrapfly/fingerprint-generator", + "files": [ + "fingerprint-network.json.zst", + "values.dat.zst", + "values.json.zst" + ], + "file_sha256": { + "fingerprint-network.json.zst": "e1b0a7e60837c347f4b7d5dad4a20c356d521e0593e1bf4a8be39ea1e6a41ac4", + "values.dat.zst": "3da2cf0891a4a85ef6458f0fbdf9346acfcd04e5fd279a0ea22d7919e4eaf122", + "values.json.zst": "294decde5b6a1a52ed53d50a894130fa5c58f7cc804b78198f5c33f55b3ba66f" + }, + "note": "fpgen's model data, pinned. fpgen fetches this itself on first import with TLS verification DISABLED, no checksum, and a release selection that cannot reach this tag -- see pythonlib/camoufox/fpgen_model.py. Bump by editing this file and its twins, pythonlib/camoufox/fpgen-model.json and typescript/src/fpgen/pin.ts (tests fail if they disagree); the sha256 values are the gate." +} diff --git a/pythonlib/camoufox/fpgen_model.py b/pythonlib/camoufox/fpgen_model.py new file mode 100644 index 000000000..28d39b880 --- /dev/null +++ b/pythonlib/camoufox/fpgen_model.py @@ -0,0 +1,168 @@ +"""fpgen's model, installed from the pinned release before fpgen is imported. + +Left to itself, `import fpgen` downloads its model from the first release the +GitHub API lists, with TLS verification off and no checksum, and downloads it +again whenever the files are five weeks old (fpgen/pkgman.py). The first +release listed is model-4/2025, whose WebGL records have no vendor or renderer, +so every generated launch failed with KeyError: 'vendor'. + +So fpgen is only ever imported through load_fpgen(), which first installs the +release named by fpgen-model.json (a copy of scripts/data/fpgen-model.json), +checks the archive and each file against its sha256, and dates the files in the +future so fpgen's five-week refresh never replaces them. The data directory +layout, including the `.pinned-model` stamp, is the one +scripts/pin-fpgen-model.py and the TypeScript launcher write. +""" + +import hashlib +import importlib.util +import json +import os +import shutil +import sys +import tempfile +import time +from pathlib import Path +from threading import Lock +from types import ModuleType +from typing import Optional +from zipfile import ZipFile + +from .exceptions import CorruptedDownload, FpgenModelError +from .pkgman import verify_sha256, webdl + +PIN = json.loads((Path(__file__).parent / 'fpgen-model.json').read_text(encoding='utf-8')) + +# Holds the archive's sha256, written last: a stamp means the install finished. +STAMP = '.pinned-model' + +# `python -m fpgen decompress` replaces the archive's files with these, which +# fpgen then reads in preference to them. +DECOMPRESSED_FILES = ('fingerprint-network.json', 'values.json', 'values.dat') + +# 2100-01-01T00:00:00Z. fpgen refetches files whose mtime is older than this +# window (pkgman.files_are_recent). +PINNED_MTIME = 4102444800 +FPGEN_MAX_AGE_S = 5 * 7 * 24 * 3600 + +# fpgen fetches a model from this URL instead of GitHub. A caller who set it +# chose their model, so it is left alone. +CUSTOM_MODEL_ENV = 'FPGEN_MODEL_URL' + +_LOCK = Lock() +_ready = False + + +def fpgen_data_dir() -> Path: + """Where fpgen reads its model, found without importing fpgen (which fetches).""" + spec = importlib.util.find_spec('fpgen') + if spec is None or spec.origin is None: + raise FpgenModelError('fpgen is not installed.') + return Path(spec.origin).parent / 'data' + + +def load_fpgen() -> ModuleType: + """fpgen, imported only once the pinned model is in place.""" + global _ready + if not _ready: + ensure_fpgen_model() + _ready = True + import fpgen + import fpgen.utils # noqa: F401 -- callers use fpgen.utils._lookup_possibilities + + return fpgen + + +def is_pinned(data_dir: Optional[Path] = None) -> bool: + """Whether the pinned model's files are in place, whatever wrote them.""" + data_dir = data_dir or fpgen_data_dir() + return all( + (data_dir / name).exists() and _hash(data_dir / name) == digest + for name, digest in PIN['file_sha256'].items() + ) + + +def ensure_fpgen_model(data_dir: Optional[Path] = None, force: bool = False) -> None: + """Install the pinned model unless it is already there, and keep fpgen from + replacing it. Hashing the installed files takes ~2 ms, and catches a model + fpgen fetched over an earlier install.""" + if os.getenv(CUSTOM_MODEL_ENV): + return + data_dir = data_dir or fpgen_data_dir() + with _LOCK: + if any((data_dir / name).exists() for name in DECOMPRESSED_FILES): + raise FpgenModelError( + f"fpgen's model in {data_dir} is decompressed, so it cannot be checked against " + f"the pinned {PIN['tag']}. Remove it with `python -m fpgen remove`; Camoufox " + 'then installs the pinned model.' + ) + try: + if force or not is_pinned(data_dir): + _install(data_dir) + _stamp(data_dir) + except PermissionError as e: + raise FpgenModelError( + f"fpgen's model directory is not writable by this user: {data_dir}\n" + 'Install the model as its owner once, e.g. while building an image: ' + 'camoufox fetch' + ) from e + + +def _install(data_dir: Path) -> None: + print(f"Downloading fpgen model {PIN['tag']}: {PIN['url']}", file=sys.stderr) + buffer = webdl(PIN['url'], progress_callback=lambda done, total: None) + verify_sha256(buffer, PIN['sha256'], 'fpgen model') + + data_dir.mkdir(parents=True, exist_ok=True) + staging = Path(tempfile.mkdtemp(prefix='.model-', dir=data_dir)) + try: + with ZipFile(buffer) as zf: + if sorted(zf.namelist()) != sorted(PIN['file_sha256']): + raise CorruptedDownload( + f"fpgen model archive holds {sorted(zf.namelist())}, " + f"expected {sorted(PIN['file_sha256'])}." + ) + # Members are read by name, never extracted by path. + for name, expected in PIN['file_sha256'].items(): + data = zf.read(name) + if hashlib.sha256(data).hexdigest() != expected: + raise CorruptedDownload(f'fpgen model member {name} does not match its pinned sha256.') + (staging / name).write_bytes(data) + (data_dir / STAMP).unlink(missing_ok=True) + for name in PIN['file_sha256']: + # Dated before the move, so fpgen never sees it as stale; each + # replace is atomic, so a concurrent reader never sees a torn file. + os.utime(staging / name, (PINNED_MTIME, PINNED_MTIME)) + os.replace(staging / name, data_dir / name) + finally: + shutil.rmtree(staging, ignore_errors=True) + + +def _stamp(data_dir: Path) -> None: + """Date the files past fpgen's refresh and write the stamp. Files seeded by + another user (a root-built image) may be left as they are while fpgen still + considers them current.""" + for name in PIN['file_sha256']: + path = data_dir / name + if int(path.stat().st_mtime) == PINNED_MTIME: + continue + try: + os.utime(path, (PINNED_MTIME, PINNED_MTIME)) + except PermissionError: + if path.stat().st_mtime < time.time() - FPGEN_MAX_AGE_S: + raise + stamp = data_dir / STAMP + try: + if stamp.read_text(encoding='utf-8').strip() == PIN['sha256']: + return + except OSError: + pass + stamp.write_text(PIN['sha256'] + '\n', encoding='utf-8') + + +def _hash(path: Path) -> str: + digest = hashlib.sha256() + with open(path, 'rb') as f: + for block in iter(lambda: f.read(1 << 20), b''): + digest.update(block) + return digest.hexdigest() diff --git a/pythonlib/camoufox/webgl.py b/pythonlib/camoufox/webgl.py index d31c6bc03..08fde48d5 100644 --- a/pythonlib/camoufox/webgl.py +++ b/pythonlib/camoufox/webgl.py @@ -18,6 +18,7 @@ from .coherence import gpu_fits_os from .fingerprints import _FPGEN_OS, gpu_screen_is_plausible, is_software_renderer +from .fpgen_model import load_fpgen # Extensions a release Firefox never exposes (draft extensions behind # webgl.enable-draft-extensions, or mobile-only): fpgen's corpus carries some @@ -60,9 +61,7 @@ def _filtered_extensions(target_os: str) -> FrozenSet[str]: @lru_cache(maxsize=None) def _lookup_index(node: str) -> Dict[str, str]: """fpgen's lookup index for every value of `node`, keyed by its JSON.""" - from fpgen.utils import _lookup_possibilities - - return _lookup_possibilities(node, casefold=False) + return load_fpgen().utils._lookup_possibilities(node, casefold=False) def _pin(node: str, value: Any) -> Tuple[str, str]: @@ -79,10 +78,8 @@ def _pin(node: str, value: Any) -> Tuple[str, str]: @lru_cache(maxsize=None) def _trace(target: str, target_os: str, pinned: Tuple[Tuple[str, str], ...] = ()) -> Tuple[Any, ...]: """fpgen's distribution of `target` for Firefox on `target_os`, in its order.""" - import fpgen - return tuple( - fpgen.trace( + load_fpgen().trace( target=target, browser='Firefox', os=_FPGEN_OS[target_os], diff --git a/pythonlib/tests/test_fpgen_model.py b/pythonlib/tests/test_fpgen_model.py new file mode 100644 index 000000000..04614caf5 --- /dev/null +++ b/pythonlib/tests/test_fpgen_model.py @@ -0,0 +1,336 @@ +"""fpgen's model is the pinned release, installed before fpgen is first imported. + +Left to itself, `import fpgen` downloads the first release the GitHub API +lists -- model-4/2025, whose WebGL records carry no vendor or renderer -- so a +pip-installed camoufox failed every generated launch with KeyError: 'vendor' in +webgl.py. scripts/pin-fpgen-model.py pinned the model for CI only. +""" + +import ast +import hashlib +import io +import json +import os +import subprocess +import sys +import time +import zipfile +from pathlib import Path + +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from camoufox import fpgen_model # noqa: E402 +from camoufox.exceptions import CorruptedDownload, FpgenModelError # noqa: E402 +from camoufox.fpgen_model import FPGEN_MAX_AGE_S, PINNED_MTIME, STAMP, ensure_fpgen_model # noqa: E402 + +PACKAGE = Path(fpgen_model.__file__).resolve().parent +MEMBERS = { + "fingerprint-network.json.zst": b"network" * 1000, + "values.json.zst": b"values-json" * 1000, + "values.dat.zst": b"values-dat" * 1000, +} +STALE = time.time() - FPGEN_MAX_AGE_S - 86400 + + +def _archive(members): + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + for name, data in members.items(): + zf.writestr(name, data) + return buf.getvalue() + + +def _sha256(data): + return hashlib.sha256(data).hexdigest() + + +@pytest.fixture +def pinned(monkeypatch): + """Pin a small synthetic model instead of the real release, and record the + downloads. Set `.payload` to serve other bytes than the pin describes.""" + archive = _archive(MEMBERS) + pin = { + **fpgen_model.PIN, + "size": len(archive), + "sha256": _sha256(archive), + "files": sorted(MEMBERS), + "file_sha256": {name: _sha256(data) for name, data in MEMBERS.items()}, + } + monkeypatch.setattr(fpgen_model, "PIN", pin) + monkeypatch.delenv("FPGEN_MODEL_URL", raising=False) + + class Downloads(list): + payload = archive + + downloads = Downloads() + + def fake_webdl(url, desc=None, buffer=None, bar=True, progress_callback=None): + downloads.append(url) + return io.BytesIO(downloads.payload) + + monkeypatch.setattr(fpgen_model, "webdl", fake_webdl) + return downloads + + +def _write(data_dir, members, mtime=None, stamp=None): + data_dir.mkdir(parents=True, exist_ok=True) + for name, data in members.items(): + path = data_dir / name + path.write_bytes(data) + if mtime is not None: + os.utime(path, (mtime, mtime)) + if stamp is not None: + (data_dir / STAMP).write_text(stamp + "\n") + + +def _mtimes(data_dir): + return {int((data_dir / name).stat().st_mtime) for name in MEMBERS} + + +def test_only_the_model_module_imports_fpgen(): + """Every fpgen import goes through fpgen_model.load_fpgen(), which installs + the pinned model first. A direct import anywhere else lets fpgen fetch + model-4/2025 before the pin is in place.""" + offenders = [] + for path in sorted(PACKAGE.rglob("*.py")): + if path.name == "fpgen_model.py": + continue + for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))): + if isinstance(node, ast.Import): + names = [alias.name for alias in node.names] + elif isinstance(node, ast.ImportFrom) and node.level == 0: + names = [node.module or ""] + else: + continue + if any(name == "fpgen" or name.startswith("fpgen.") for name in names): + offenders.append(f"{path.relative_to(PACKAGE)}:{node.lineno}") + assert offenders == [] + + +def test_missing_model_is_installed_verified_and_stamped(tmp_path, pinned): + data_dir = tmp_path / "data" + ensure_fpgen_model(data_dir) + + assert pinned == [fpgen_model.PIN["url"]] + for name, data in MEMBERS.items(): + assert (data_dir / name).read_bytes() == data + assert _mtimes(data_dir) == {PINNED_MTIME} + # The same stamp scripts/pin-fpgen-model.py and the TypeScript launcher write. + assert (data_dir / STAMP).read_text().strip() == fpgen_model.PIN["sha256"] + assert sorted(p.name for p in data_dir.iterdir()) == sorted([*MEMBERS, STAMP]) + + +def test_pinned_model_is_not_downloaded_again(tmp_path, pinned): + data_dir = tmp_path / "data" + ensure_fpgen_model(data_dir) + ensure_fpgen_model(data_dir) + + assert len(pinned) == 1 + + +def test_stale_pinned_model_is_restamped_not_refetched(tmp_path, pinned): + """Five weeks after install fpgen would fetch model-4/2025 over the pin.""" + data_dir = tmp_path / "data" + _write(data_dir, MEMBERS, mtime=STALE, stamp=fpgen_model.PIN["sha256"]) + + ensure_fpgen_model(data_dir) + + assert pinned == [] + assert _mtimes(data_dir) == {PINNED_MTIME} + + +def test_a_model_fpgen_overwrote_under_a_valid_stamp_is_replaced(tmp_path, pinned): + """The stamp outlives fpgen's own five-week refetch, so files are hashed.""" + data_dir = tmp_path / "data" + _write(data_dir, {**MEMBERS, "values.dat.zst": b"model-4/2025"}, stamp=fpgen_model.PIN["sha256"]) + + ensure_fpgen_model(data_dir) + + assert len(pinned) == 1 + assert (data_dir / "values.dat.zst").read_bytes() == MEMBERS["values.dat.zst"] + + +def test_matching_files_without_a_stamp_are_adopted(tmp_path, pinned): + data_dir = tmp_path / "data" + _write(data_dir, MEMBERS) + + ensure_fpgen_model(data_dir) + + assert pinned == [] + assert (data_dir / STAMP).read_text().strip() == fpgen_model.PIN["sha256"] + + +def test_partial_model_is_completed(tmp_path, pinned): + data_dir = tmp_path / "data" + _write(data_dir, {"values.json.zst": MEMBERS["values.json.zst"]}) + + ensure_fpgen_model(data_dir) + + assert len(pinned) == 1 + assert all((data_dir / name).exists() for name in MEMBERS) + + +def test_force_reinstalls(tmp_path, pinned): + data_dir = tmp_path / "data" + ensure_fpgen_model(data_dir) + ensure_fpgen_model(data_dir, force=True) + + assert len(pinned) == 2 + + +@pytest.mark.parametrize( + "tamper", + [ + pytest.param(lambda a: a[:-1], id="truncated-archive"), + pytest.param(lambda a: _archive({**MEMBERS, "values.dat.zst": b"x"}), id="member-changed"), + ], +) +def test_tampered_download_installs_nothing(tmp_path, pinned, tamper): + data_dir = tmp_path / "data" + pinned.payload = tamper(_archive(MEMBERS)) + + with pytest.raises(CorruptedDownload): + ensure_fpgen_model(data_dir) + + assert not data_dir.exists() or list(data_dir.iterdir()) == [] + + +def test_member_hashes_are_checked_even_when_the_archive_hash_matches(tmp_path, pinned): + bad = _archive({**MEMBERS, "values.dat.zst": b"x"}) + pinned.payload = bad + fpgen_model.PIN.update(size=len(bad), sha256=_sha256(bad)) + + with pytest.raises(CorruptedDownload, match="values.dat.zst"): + ensure_fpgen_model(tmp_path / "data") + + +def test_decompressed_model_fails_loudly(tmp_path, pinned): + """`python -m fpgen decompress` leaves files the pin cannot verify, and fpgen + reads them in preference to the verified ones.""" + data_dir = tmp_path / "data" + _write(data_dir, {name: b"{}" for name in fpgen_model.DECOMPRESSED_FILES}) + + with pytest.raises(FpgenModelError, match="fpgen remove"): + ensure_fpgen_model(data_dir) + assert pinned == [] + + +def test_custom_model_url_is_left_to_fpgen(tmp_path, pinned, monkeypatch): + monkeypatch.setenv("FPGEN_MODEL_URL", "https://example.invalid/model.zip") + data_dir = tmp_path / "data" + + ensure_fpgen_model(data_dir) + + assert pinned == [] + assert not data_dir.exists() + + +needs_non_root = pytest.mark.skipif( + hasattr(os, "geteuid") and os.geteuid() == 0, reason="root ignores file permissions" +) + + +@pytest.fixture +def read_only(tmp_path): + data_dir = tmp_path / "data" + yield data_dir + if data_dir.exists(): + data_dir.chmod(0o755) + + +@needs_non_root +def test_read_only_missing_model_names_the_fix(read_only, pinned): + read_only.mkdir() + read_only.chmod(0o555) + + with pytest.raises(FpgenModelError, match="camoufox fetch"): + ensure_fpgen_model(read_only) + + +@needs_non_root +def test_read_only_current_pinned_model_is_used(read_only, pinned): + """A root-seeded image run as another user: fpgen accepts the files as they + are for five weeks, so the launch does not need to stamp them.""" + _write(read_only, MEMBERS, stamp=fpgen_model.PIN["sha256"]) + read_only.chmod(0o555) + for name in MEMBERS: + (read_only / name).chmod(0o444) + + ensure_fpgen_model(read_only) + + assert pinned == [] + + +def test_install_writes_nothing_to_stdout(tmp_path, pinned, capfd): + """A caller's stdout may be data (a probe printing JSON).""" + ensure_fpgen_model(tmp_path / "data") + + out, err = capfd.readouterr() + assert out == "" + assert "fpgen model" in err + + +def test_the_packaged_pin_is_the_repo_pin(): + """The package cannot ship scripts/, so it carries a copy. Bump both.""" + repo_pin = PACKAGE.parents[1] / "scripts" / "data" / "fpgen-model.json" + assert fpgen_model.PIN == json.loads(repo_pin.read_text(encoding="utf-8")) + + +def test_the_real_pin_is_complete(): + pin = fpgen_model.PIN + assert pin["url"].startswith("https://github.com/") + assert f"/download/{pin['tag']}/" in pin["url"] + assert sorted(pin["file_sha256"]) == sorted(pin["files"]) + + +def test_constants_match_the_installed_fpgen(): + """fpgen's file names, data directory and five-week window are its own; if + a release changes them, this module would install a model fpgen cannot see. + FPGEN_NO_INIT keeps fpgen from loading (or fetching) while it is read.""" + probe = ( + "import json, fpgen.pkgman as p;" + "print(json.dumps({" + "'dir': str(p.DATA_DIR)," + "'compressed': sorted(v.name for v in p.FILE_PAIRS.values())," + "'decompressed': sorted(k.name for k in p.FILE_PAIRS.keys())}))" + ) + env = {**os.environ, "FPGEN_NO_INIT": "1"} + out = subprocess.run([sys.executable, "-c", probe], env=env, capture_output=True, text=True, check=True) + seen = json.loads(out.stdout.strip().splitlines()[-1]) + + assert seen["dir"] == str(fpgen_model.fpgen_data_dir()) + assert seen["compressed"] == sorted(fpgen_model.PIN["files"]) + assert seen["decompressed"] == sorted(fpgen_model.DECOMPRESSED_FILES) + assert "timedelta(weeks=5)" in (fpgen_model.fpgen_data_dir().parent / "pkgman.py").read_text() + + +_IMPORT_ORDER = """ +import sys +from camoufox import fpgen_model +real = fpgen_model.ensure_fpgen_model +seen = [] +def spy(*args, **kwargs): + seen.append("fpgen" in sys.modules) + return real(*args, **kwargs) +fpgen_model.ensure_fpgen_model = spy +from camoufox import {module} +{call} +print(seen) +""" + + +@pytest.mark.parametrize( + "module, call", + [ + pytest.param("fingerprints", "fingerprints._generator()", id="generate"), + pytest.param("webgl", "webgl.firefox_gpus('win')", id="preset-or-webgl_config"), + ], +) +def test_each_entry_point_installs_the_model_before_fpgen_loads(module, call): + script = _IMPORT_ORDER.format(module=module, call=call) + out = subprocess.run( + [sys.executable, "-c", script], cwd=PACKAGE.parent, capture_output=True, text=True, check=True + ) + assert out.stdout.strip().splitlines()[-1] == "[False]" diff --git a/scripts/data/fpgen-model.json b/scripts/data/fpgen-model.json index 93d041cf8..7016ee117 100644 --- a/scripts/data/fpgen-model.json +++ b/scripts/data/fpgen-model.json @@ -10,5 +10,10 @@ "values.dat.zst", "values.json.zst" ], - "note": "fpgen's model data, pinned. fpgen fetches this itself on first import with TLS verification DISABLED, no checksum, and a release selection that cannot reach this tag -- see scripts/pin-fpgen-model.py. Bump by editing this file; the sha256 is the gate." + "file_sha256": { + "fingerprint-network.json.zst": "e1b0a7e60837c347f4b7d5dad4a20c356d521e0593e1bf4a8be39ea1e6a41ac4", + "values.dat.zst": "3da2cf0891a4a85ef6458f0fbdf9346acfcd04e5fd279a0ea22d7919e4eaf122", + "values.json.zst": "294decde5b6a1a52ed53d50a894130fa5c58f7cc804b78198f5c33f55b3ba66f" + }, + "note": "fpgen's model data, pinned. fpgen fetches this itself on first import with TLS verification DISABLED, no checksum, and a release selection that cannot reach this tag -- see pythonlib/camoufox/fpgen_model.py. Bump by editing this file and its twins, pythonlib/camoufox/fpgen-model.json and typescript/src/fpgen/pin.ts (tests fail if they disagree); the sha256 values are the gate." } diff --git a/scripts/pin-fpgen-model.py b/scripts/pin-fpgen-model.py index ec3a1847a..67b00c980 100755 --- a/scripts/pin-fpgen-model.py +++ b/scripts/pin-fpgen-model.py @@ -1,38 +1,10 @@ #!/usr/bin/env python3 -"""Install a pinned fpgen model, instead of letting fpgen fetch one itself. +"""Install the pinned fpgen model before anything imports fpgen. -WHY THIS EXISTS ---------------- -`fpgen` (scrapfly/fingerprint-generator) is where Camoufox's synthetic -fingerprints come from. It does not ship its model; it downloads one the first -time it is imported, and again whenever the files on disk are over five weeks -old. That fetch has four problems, all of them in fpgen/pkgman.py: - - 1. TLS verification is DISABLED on both the API call and the download - (`httpx.get(..., verify=False)`, `httpx.stream(..., verify=False)`), so - anyone on the network path can serve the model. - 2. The archive is never checksummed -- it goes straight into - `zipfile.ZipFile(...).extractall(DATA_DIR)`, which is also not guarded - against path traversal. - 3. The GitHub API is called unauthenticated, sharing a 60-request/hour limit - with every other job on the runner's IP. - 4. It takes the FIRST release the API lists and that release's first `.zip`. - GitHub sorts releases by `created_at`, and `model-4/2025` and - `model-2/2026` carry an identical `created_at` (2025-03-22T11:41:12Z, - inherited from the tag's commit), so the tie breaks toward the lower id - and fpgen always lands on the 2025 model. The 2026 model is unreachable - through that path. - -So an unpinned Camoufox generates from an April-2025 corpus -- newest Firefox -137, newest GPU an RTX 40 -- and cannot be talked into anything newer. - -This script puts the model named by scripts/data/fpgen-model.json where fpgen -looks, with TLS verification on and the sha256 checked, before anything imports -fpgen. fpgen then finds recent files and never calls the network. - -Locating the data directory must NOT import fpgen: importing is what triggers -the download this script exists to prevent. importlib.util.find_spec() reads the -module's origin without executing it. +The launcher does this itself on first use (pythonlib/camoufox/fpgen_model.py, +which explains why fpgen's own download is not used). This entry point exists +for jobs that need the model in place before they start: CI, image builds, and +the TypeScript golden recorder. Usage: python3 scripts/pin-fpgen-model.py # install if not already pinned @@ -41,84 +13,12 @@ """ import argparse -import hashlib -import importlib.util -import json -import os -import ssl import sys -import tempfile -import urllib.request -import zipfile - -REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -SPEC = os.path.join(REPO, 'scripts', 'data', 'fpgen-model.json') -STAMP = '.pinned-model' - - -def load_spec(): - with open(SPEC, encoding='utf-8') as fh: - return json.load(fh) - - -def data_dir(): - """fpgen's data directory, found WITHOUT importing fpgen.""" - spec = importlib.util.find_spec('fpgen') - if spec is None or not spec.origin: - sys.exit('fpgen is not installed; `pip install -e pythonlib` first') - return os.path.join(os.path.dirname(spec.origin), 'data') - -def digest(path, chunk=1 << 20): - h = hashlib.sha256() - with open(path, 'rb') as fh: - for block in iter(lambda: fh.read(chunk), b''): - h.update(block) - return h.hexdigest() - - -def is_pinned(spec, d): - """True when the pinned model is already in place.""" - try: - with open(os.path.join(d, STAMP), encoding='utf-8') as fh: - if fh.read().strip() != spec['sha256']: - return False - except OSError: - return False - return all(os.path.exists(os.path.join(d, f)) for f in spec['files']) - - -def download(spec, dest): - # Verification ON, unlike fpgen's own fetch. create_default_context() - # verifies the chain and the hostname. - ctx = ssl.create_default_context() - print(f'fetching {spec["url"]}', file=sys.stderr) - req = urllib.request.Request(spec['url'], headers={'User-Agent': 'camoufox-build'}) - with urllib.request.urlopen(req, context=ctx, timeout=60) as r, open(dest, 'wb') as fh: - while chunk := r.read(1 << 20): - fh.write(chunk) - size = os.path.getsize(dest) - if spec.get('size') and size != spec['size']: - sys.exit(f'size mismatch: got {size}, expected {spec["size"]}') - got = digest(dest) - if got != spec['sha256']: - sys.exit(f'sha256 mismatch:\n got {got}\n expected {spec["sha256"]}') - - -def install(spec, archive, d): - os.makedirs(d, exist_ok=True) - with zipfile.ZipFile(archive) as z: - # fpgen extracts without checking; do not copy that. A member escaping - # the data directory would write anywhere the build user can. - for name in z.namelist(): - target = os.path.realpath(os.path.join(d, name)) - if not target.startswith(os.path.realpath(d) + os.sep): - sys.exit(f'refusing to extract outside the data dir: {name}') - z.extractall(d) - # Written last: an interrupted extract leaves no stamp, so the next run - # reinstalls rather than trusting a partial model. - with open(os.path.join(d, STAMP), 'w', encoding='utf-8') as fh: - fh.write(spec['sha256'] + '\n') +try: + from camoufox.fpgen_model import PIN, ensure_fpgen_model, fpgen_data_dir, is_pinned +except ImportError: + sys.exit('camoufox is not installed; `pip install -e pythonlib` first') def main(): @@ -128,26 +28,16 @@ def main(): ap.add_argument('--force', action='store_true', help='reinstall even if already pinned') args = ap.parse_args() - spec = load_spec() - d = data_dir() - if args.check: - if is_pinned(spec, d): - print(f'OK: fpgen model pinned to {spec["tag"]}') + if is_pinned(): + print(f'OK: fpgen model pinned to {PIN["tag"]}') return 0 - print(f'fpgen model is NOT pinned to {spec["tag"]}; run scripts/pin-fpgen-model.py', + print(f'fpgen model is NOT pinned to {PIN["tag"]}; run scripts/pin-fpgen-model.py', file=sys.stderr) return 1 - if is_pinned(spec, d) and not args.force: - print(f'OK: fpgen model already pinned to {spec["tag"]}', file=sys.stderr) - return 0 - - with tempfile.TemporaryDirectory() as tmp: - archive = os.path.join(tmp, 'model-release.zip') - download(spec, archive) - install(spec, archive, d) - print(f'OK: pinned fpgen model {spec["tag"]} -> {d}', file=sys.stderr) + ensure_fpgen_model(force=args.force) + print(f'OK: fpgen model {PIN["tag"]} pinned in {fpgen_data_dir()}', file=sys.stderr) return 0 diff --git a/typescript/src/fpgen/pin.ts b/typescript/src/fpgen/pin.ts index db31d4b49..8e2ff973b 100644 --- a/typescript/src/fpgen/pin.ts +++ b/typescript/src/fpgen/pin.ts @@ -6,7 +6,7 @@ * tests/fpgen-model.test.ts fails if the two ever disagree, so bumping the * model means editing both (the sha256 is the gate in each). * - * See scripts/pin-fpgen-model.py for why fpgen's own downloader is not used: + * See pythonlib/camoufox/fpgen_model.py for why fpgen's own downloader is not used: * it disables TLS verification, never checks a digest, and its "first listed * release" rule cannot reach this tag. */ @@ -19,6 +19,8 @@ export interface ModelPin { readonly url: string; readonly repo: string; readonly files: readonly string[]; + /** Each file's sha256, for checking an installed model without its archive. */ + readonly file_sha256: Readonly>; } export const MODEL_PIN: ModelPin = { @@ -29,4 +31,12 @@ export const MODEL_PIN: ModelPin = { url: "https://github.com/scrapfly/fingerprint-generator/releases/download/model-2/2026/model-release.zip", repo: "scrapfly/fingerprint-generator", files: ["fingerprint-network.json.zst", "values.dat.zst", "values.json.zst"], + file_sha256: { + "fingerprint-network.json.zst": + "e1b0a7e60837c347f4b7d5dad4a20c356d521e0593e1bf4a8be39ea1e6a41ac4", + "values.dat.zst": + "3da2cf0891a4a85ef6458f0fbdf9346acfcd04e5fd279a0ea22d7919e4eaf122", + "values.json.zst": + "294decde5b6a1a52ed53d50a894130fa5c58f7cc804b78198f5c33f55b3ba66f", + }, }; From 093b95ff6e2a84174333534aff11cd1d97d46076 Mon Sep 17 00:00:00 2001 From: coffeegrind123 <103264516+coffeegrind123@users.noreply.github.com> Date: Sun, 27 Sep 2026 17:43:42 +0300 Subject: [PATCH 02/12] fix(policies): restore Web Serial, which enterprise policies turn off Shipping distribution/policies.json puts Firefox in enterprise-policy mode, and since Firefox 151 that mode sets the default of dom.webserial.enabled to false (EnterprisePoliciesParent.sys.mjs). So navigator.serial, Serial and SerialPort are missing, while stock Firefox 152 exposes them on secure pages (the pref defaults to true on desktop). A page can see the difference. DefaultSerialGuardSetting: 3 sets the default back to true without locking it (Policies.sys.mjs). stock-parity-probes.py now checks that navigator.serial exists on its secure-context probe page. Co-Authored-By: Claude Opus 5.5 (1M context) (cherry picked from commit 6f5e1e4c0390cd3c9bb1a66b8622fe4651bc0893) --- settings/distribution/policies.json | 1 + tests/patches/stock-parity-probes.py | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/settings/distribution/policies.json b/settings/distribution/policies.json index b5ec1e366..476ba4528 100644 --- a/settings/distribution/policies.json +++ b/settings/distribution/policies.json @@ -3,6 +3,7 @@ "policies": { "AppUpdateURL": "https://localhost", "DisableAppUpdate": true, + "DefaultSerialGuardSetting": 3, "OverrideFirstRunPage": "", "OverridePostUpdatePage": "", "DisableSystemAddonUpdate": true, diff --git a/tests/patches/stock-parity-probes.py b/tests/patches/stock-parity-probes.py index a6fddf570..e21f21444 100644 --- a/tests/patches/stock-parity-probes.py +++ b/tests/patches/stock-parity-probes.py @@ -23,6 +23,9 @@ about what UTC getters do timezone-relaunch a persistent profile relaunched with another timezone reports the new one, in the page and in a worker + web-serial navigator.serial exists on a secure page. Any enterprise + policy turns Web Serial off unless DefaultSerialGuardSetting + turns it back on python tests/patches/stock-parity-probes.py """ @@ -77,6 +80,9 @@ def log_message(self, *args): PROBES = """async (port) => { const out = {}; + out.secureContext = isSecureContext; + out.serial = 'serial' in navigator; + const c2d = document.createElement('canvas').getContext('2d'); out.canvasFonts = {}; for (const k of ['caption', 'icon', 'menu', 'message-box', 'small-caption', 'status-bar']) { @@ -300,6 +306,10 @@ def main() -> int: failures.append(f"timezone-cost: getHours {out['costLocalDate']:.0f} ms vs getUTCHours {out['costUTCDate']:.0f} ms") if relaunch != [["Asia/Tokyo"] * 2, ["America/Chicago"] * 2]: failures.append(f"timezone-relaunch: persistent profile reported {relaunch}") + if not out["secureContext"]: + failures.append("web-serial: the probe page is not a secure context -- check is vacuous") + elif not out["serial"]: + failures.append("web-serial: navigator.serial is missing (policies.json needs DefaultSerialGuardSetting: 3)") print() if failures: From 0e00bc76eb8a734fca25c9dcc2cf5a14d1561f9b Mon Sep 17 00:00:00 2001 From: coffeegrind123 <103264516+coffeegrind123@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:09:40 +0300 Subject: [PATCH 03/12] fix(patches): install addons once per launch, not once per window browser-init.patch installs the `addons` entries from gBrowserInit, which runs for every browser window, and Juggler opens a window for every page of every new context. Reinstalling a temporary addon restarts it: uBlock Origin restarted with nearly every new context, and a navigation its webRequest listener had suspended when a restart hit was never resumed, so page.goto timed out. Addons now install once per launch, flagged by a default-branch pref, which is never written to prefs.js. tests/patches/addons-install-once.py opens 12 contexts, one page each, and counts uBO background-page loads from the DocumentChannel log. Co-Authored-By: Claude Opus 5.5 (1M context) (cherry picked from commit c23207c9dd7336fe76182bb943e5fd89648b8235) --- patches/browser-init.patch | 21 +++-- tests/patches/addons-install-once.py | 111 +++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 8 deletions(-) create mode 100644 tests/patches/addons-install-once.py diff --git a/patches/browser-init.patch b/patches/browser-init.patch index 38699d342..95d9ab1da 100644 --- a/patches/browser-init.patch +++ b/patches/browser-init.patch @@ -1,8 +1,8 @@ diff --git a/browser/base/content/browser-init.js b/browser/base/content/browser-init.js -index 0aa4baea8e..3c964b7ad5 100644 +index 0aa4baea8e..343691b2f8 100644 --- a/browser/base/content/browser-init.js +++ b/browser/base/content/browser-init.js -@@ -44,6 +44,16 @@ var gSerialDeviceObserver = { +@@ -44,6 +44,17 @@ var gSerialDeviceObserver = { }, }; @@ -15,11 +15,12 @@ index 0aa4baea8e..3c964b7ad5 100644 + { global: "contextual" } +); + ++const ADDONS_INSTALLED_PREF = "camoufox.addons.installed"; + let _resolveDelayedStartup; var delayedStartupPromise = new Promise(resolve => { _resolveDelayedStartup = resolve; -@@ -157,7 +167,7 @@ var gBrowserInit = { +@@ -157,7 +168,7 @@ var gBrowserInit = { updateBookmarkToolbarVisibility(); // Set a sane starting width/height for all resolutions on new profiles. @@ -28,7 +29,7 @@ index 0aa4baea8e..3c964b7ad5 100644 // When the fingerprinting resistance is enabled, making sure that we don't // have a maximum window to interfere with generating rounded window dimensions. document.documentElement.setAttribute("sizemode", "normal"); -@@ -385,6 +395,22 @@ var gBrowserInit = { +@@ -385,6 +396,22 @@ var gBrowserInit = { // Update UI if browser is under remote control. gRemoteControl.updateVisualCue(); @@ -51,7 +52,7 @@ index 0aa4baea8e..3c964b7ad5 100644 // If we are given a tab to swap in, take care of it before first paint to // avoid an about:blank flash. let tabToAdopt = this.getTabToAdopt(); -@@ -448,6 +474,33 @@ var gBrowserInit = { +@@ -448,6 +475,33 @@ var gBrowserInit = { } } @@ -85,7 +86,7 @@ index 0aa4baea8e..3c964b7ad5 100644 // Wait until chrome is painted before executing code not critical to making the window visible this._boundDelayedStartup = this._delayedStartup.bind(this); window.addEventListener("MozAfterPaint", this._boundDelayedStartup); -@@ -475,9 +528,177 @@ var gBrowserInit = { +@@ -475,9 +529,181 @@ var gBrowserInit = { } } @@ -156,9 +157,13 @@ index 0aa4baea8e..3c964b7ad5 100644 + window.resizeBy(width - innerWidth, height + toolbarTop - innerHeight); + } + -+ // Install addons if specified ++ // Install addons once per launch: this runs for every window, Juggler opens ++ // one per page, and reinstalling a temporary addon restarts it. A default ++ // pref is never written to prefs.js. + let addonPaths = ChromeUtils.camouGetStringList("addons"); -+ if (addonPaths?.length) { ++ let defaults = Services.prefs.getDefaultBranch(""); ++ if (addonPaths?.length && !defaults.getBoolPref(ADDONS_INSTALLED_PREF, false)) { ++ defaults.setBoolPref(ADDONS_INSTALLED_PREF, true); + Promise.all(addonPaths.map(path => this.installTemporaryAddon(path))) + .then(addons => ChromeUtils.camouDebug("Installed " + addons.length + " addon(s)")) + .catch(e => ChromeUtils.camouDebug("Failed to install addons: " + e)); diff --git a/tests/patches/addons-install-once.py b/tests/patches/addons-install-once.py new file mode 100644 index 000000000..8dd9831f0 --- /dev/null +++ b/tests/patches/addons-install-once.py @@ -0,0 +1,111 @@ +""" +Verify the `addons` config is installed once per launch (browser-init.patch). + +browser-init.patch installs the `addons` entries (uBlock Origin by default) as +temporary addons from gBrowserInit, which runs for every browser window, and +Juggler opens a window for every page of every new context. Installing an +already-installed temporary addon restarts it. A navigation that uBO's +webRequest listener had suspended when the restart hit is never resumed, so +page.goto times out (daijro/camoufox#185). + + contexts create CONTEXTS contexts, one page each, and navigate it + once uBO's background page loaded exactly once (MOZ_LOG + DocumentChannel records every document load in the parent) + no hang every navigation completed + + python tests/patches/addons-install-once.py +""" + +import functools +import http.server +import os +import re +import shutil +import sys +import tempfile +import threading +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from helpers import resolve_binary # noqa: E402 + +CONTEXTS = 12 +# A request dropped by a restart never finishes. A healthy local load finishes +# well inside this even when uBO holds it while compiling its filter lists. +GOTO_TIMEOUT_MS = 60000 +BACKGROUND_LOAD = re.compile(r"DocumentChannelParent Init \[this=\w+, uri=moz-extension://[^/]+/background") + + +class Quiet(http.server.SimpleHTTPRequestHandler): + def log_message(self, *a): + pass + + +class QuietServer(http.server.ThreadingHTTPServer): + # A page torn down mid-response breaks the pipe; the default handler's + # traceback would replace the verdict as the tail of the guard's output. + def handle_error(self, request, client_address): + pass + + +def background_loads(log_dir: Path) -> int: + # MOZ_LOG_FILE gets a suffix per process; count every file rather than + # guess which one is the parent's. + return sum(len(BACKGROUND_LOAD.findall(p.read_text(errors="replace"))) + for p in log_dir.glob("log*")) + + +def main() -> int: + from camoufox.sync_api import Camoufox + + binary = resolve_binary() + site = tempfile.mkdtemp() + Path(site, "p.html").write_text("x") + srv = QuietServer(("127.0.0.1", 0), functools.partial(Quiet, directory=site)) + threading.Thread(target=srv.serve_forever, daemon=True).start() + url = f"http://127.0.0.1:{srv.server_address[1]}/p.html" + + log_dir = Path(tempfile.mkdtemp()) + env = {**os.environ, "MOZ_LOG": "DocumentChannel:5", "MOZ_LOG_FILE": str(log_dir / "log")} + created = 0 + hang = None + try: + with Camoufox(headless=True, executable_path=str(binary), env=env, + i_know_what_im_doing=True) as browser: + for _ in range(CONTEXTS): + ctx = browser.new_context() + page = ctx.new_page() + try: + page.goto(url, timeout=GOTO_TIMEOUT_MS) + except Exception as exc: + hang = f"{type(exc).__name__}: {str(exc).splitlines()[0]}" + break + ctx.close() + created += 1 + finally: + srv.shutdown() + shutil.rmtree(site, ignore_errors=True) + loads = background_loads(log_dir) + shutil.rmtree(log_dir, ignore_errors=True) + + print(f"contexts: {created}/{CONTEXTS} navigated, uBO background page loads: {loads}" + + (f", hang: {hang}" if hang else "")) + print() + failures = [] + if loads == 0: + failures.append("uBO's background page never loaded -- the addon did not start, check is vacuous") + elif loads > 1: + opened = created + (1 if hang else 0) + failures.append(f"uBO restarted: its background page loaded {loads} times across {opened} contexts") + if hang: + failures.append(f"navigation {created + 1} never completed ({hang})") + if failures: + for f in failures: + print(f"FAIL: {f}") + return 1 + print("PASS: addons installed once per launch; every navigation completed.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From bd32ad2edd92327a4d248e3d7ef72f6d3f70060a Mon Sep 17 00:00:00 2001 From: coffeegrind123 <103264516+coffeegrind123@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:24:49 +0300 Subject: [PATCH 04/12] fix(juggler): enable Responsive Design Mode only for isMobile Juggler set inRDMPane on every page with an emulated viewport, which is Playwright's default for new contexts. RDM is devtools' mobile mode, and a page can read it: scrollbars become overlay scrollbars with no layout width, so with classic scrollbars pinned a page measured 12 px without a viewport and 0 px with one. Navigator, screen and window getters also take RDM branches. The viewport itself is sized by the browser element and does not need RDM. Playwright's Juggler has enabled RDM only for isMobile since microsoft/playwright#41859. Do the same: Browser.setDefaultViewport and Page.setViewportSize already accept isMobile, and it is now kept and applied instead of dropped, so is_mobile=True still gets RDM. The new guard tests/patches/viewport-no-rdm.py fails on the old Juggler (0 px in a viewport context against 12 px without) and passes on this one. It also checks that is_mobile=True still turns RDM on. Five upstream Playwright tests go in ci/skiplist.yml. They assume headless scrollbars take no width, which upstream gets by hiding them with a style sheet that Camoufox removed. They only passed here because of RDM's overlay scrollbars. Co-Authored-By: Claude Opus 5.5 (1M context) (cherry picked from commit 77d69ac346cb7655f27564a469229dbfe9a656f1) --- additions/juggler/TargetRegistry.js | 17 ++++- additions/juggler/protocol/PageHandler.js | 4 +- ci/README.md | 12 +-- ci/skiplist.yml | 40 ++++++++++ tests/patches/viewport-no-rdm.py | 89 +++++++++++++++++++++++ 5 files changed, 152 insertions(+), 10 deletions(-) create mode 100644 tests/patches/viewport-no-rdm.py diff --git a/additions/juggler/TargetRegistry.js b/additions/juggler/TargetRegistry.js index e2bb65adc..e5dc2c7ff 100644 --- a/additions/juggler/TargetRegistry.js +++ b/additions/juggler/TargetRegistry.js @@ -484,6 +484,7 @@ export class PageTarget { this._linkedBrowser = tab.linkedBrowser; this._browserContext = browserContext; this._viewportSize = undefined; + this._isMobile = undefined; this._zoom = 1; this._initialDPPX = this._linkedBrowser.browsingContext.overrideDPPX; this._url = 'about:blank'; @@ -620,6 +621,7 @@ export class PageTarget { this.updateUserAgent(browsingContext); this.updatePlatform(browsingContext); this.updateDPPXOverride(browsingContext); + this.updateMobileEmulation(browsingContext); this.updateZoom(browsingContext); this.updateEmulatedMedia(browsingContext); this.updateColorSchemeOverride(browsingContext); @@ -670,6 +672,13 @@ export class PageTarget { browsingContext.overrideDPPX = dppx; } + updateMobileEmulation(browsingContext = undefined) { + // Responsive Design Mode is devtools' mobile mode (overlay scrollbars, and + // RDM branches in screen, window and navigator getters), all readable by + // the page. As in Playwright's Juggler, only isMobile asks for it. + (browsingContext || this._linkedBrowser.browsingContext).inRDMPane = !!(this._isMobile ?? this._browserContext.isMobile); + } + async updateZoom(browsingContext = undefined) { browsingContext ||= this._linkedBrowser.browsingContext; // Update dpr first, and then UI zoom. @@ -699,6 +708,7 @@ export class PageTarget { async updateViewportSize() { await waitForWindowReady(this._window); this.updateDPPXOverride(); + this.updateMobileEmulation(); // Viewport size is defined by three arguments: // 1. default size. Could be explicit if set as part of `window.open` call, e.g. @@ -718,7 +728,6 @@ export class PageTarget { 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; @@ -732,7 +741,6 @@ export class PageTarget { 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', { @@ -794,8 +802,9 @@ export class PageTarget { await this._channel.connect('').send('setInterceptFileChooserDialog', enabled).catch(e => {}); } - async setViewportSize(viewportSize) { + async setViewportSize(viewportSize, isMobile) { this._viewportSize = viewportSize; + this._isMobile = isMobile; await this.updateViewportSize(); } @@ -1198,6 +1207,7 @@ class BrowserContext { this.downloadOptions = undefined; this.defaultViewportSize = undefined; this.deviceScaleFactor = undefined; + this.isMobile = undefined; this.defaultUserAgent = null; this.defaultPlatform = null; this.touchOverride = false; @@ -1323,6 +1333,7 @@ class BrowserContext { async setDefaultViewport(viewport) { this.defaultViewportSize = viewport ? viewport.viewportSize : undefined; this.deviceScaleFactor = viewport ? viewport.deviceScaleFactor : undefined; + this.isMobile = viewport ? viewport.isMobile : undefined; await Promise.all(Array.from(this.pages).map(page => page.updateViewportSize())); } diff --git a/additions/juggler/protocol/PageHandler.js b/additions/juggler/protocol/PageHandler.js index 774320562..894ad0c62 100644 --- a/additions/juggler/protocol/PageHandler.js +++ b/additions/juggler/protocol/PageHandler.js @@ -241,8 +241,8 @@ export class PageHandler { }); } - async ['Page.setViewportSize']({viewportSize}) { - await this._pageTarget.setViewportSize(viewportSize === null ? undefined : viewportSize); + async ['Page.setViewportSize']({viewportSize, isMobile}) { + await this._pageTarget.setViewportSize(viewportSize === null ? undefined : viewportSize, isMobile); } async ['Page.setZoom']({zoom}) { diff --git a/ci/README.md b/ci/README.md index 1d4f17bb7..8442faf43 100644 --- a/ci/README.md +++ b/ci/README.md @@ -263,7 +263,7 @@ isolation itself regresses. entry has to claim a test cannot pass in *either* world, or the suite would have counted it as a fallback rather than a failure. -Fourteen tests are deselected outright by [`ci/skiplist.yml`](skiplist.yml), which +Seventeen tests are deselected outright by [`ci/skiplist.yml`](skiplist.yml), which requires a stated reason per entry — `ci/summarize.py` fails the run on an unreasoned one. @@ -276,21 +276,23 @@ leaving them bare. `ci/run_skiplist_audit.py` now runs every entry with the skiplist disabled and **fails the build if a skipped test passes**. It is cheap precisely because a -correct skiplist is short — twelve tests, a few seconds — and it is what keeps the +correct skiplist is short — seventeen tests, a few seconds — and it is what keeps the list from drifting back into a place failing tests go to disappear. ```bash python3 -m ci.run_skiplist_audit --binary /path/to/camoufox-bin ``` -What remains, 12 tests: two `test_keyboard.py` +What remains, 17 tests: two `test_keyboard.py` tests that assert a shifted character arrives without Shift, which Camoufox presses as a real keyboard would; six client-certificate tests (async and sync) that need the **browser** to present a certificate during the TLS handshake — the two that go through the Node driver's own request context instead pass, and are not skipped; two upstream expectations that encode a stock-Firefox quirk, -replaced by `tests/camoufox/`; and two popup tests that rely on Playwright -shipping Firefox's popup blocker off, which Camoufox keeps on. +replaced by `tests/camoufox/`; two popup tests that rely on Playwright +shipping Firefox's popup blocker off, which Camoufox keeps on; and five layout +tests that assume headless scrollbars take no width, which Playwright gets by +hiding them and Camoufox does not do. That client-certificate split is the audit earning its place. The entry was first written as a whole module, because on a local machine all five fail — diff --git a/ci/skiplist.yml b/ci/skiplist.yml index 1f80432d7..e02f53269 100644 --- a/ci/skiplist.yml +++ b/ci/skiplist.yml @@ -174,3 +174,43 @@ skip: patches/popup-blocker-parity.patch). page.click() still works -- it synthesizes a real click, which grants a real activation -- and so does page.evaluate(() => window.open(...)). + + # --- scrollbars in an emulated viewport -------------------------------------- + # + # Upstream's headless Firefox hides content scrollbars with an agent style + # sheet, which a page can read back, so Camoufox leaves them to the platform + # look-and-feel (additions/juggler/components/Juggler.js). These tests were + # still passing only because Juggler put every viewport context into + # Responsive Design Mode, whose overlay scrollbars take no width. It no longer + # does unless isMobile is set, as in Playwright's own Juggler since + # microsoft/playwright#41859 (additions/juggler/TargetRegistry.js, guard + # tests/patches/viewport-no-rdm.py). The suite launches the bare binary + # headless on Linux, which draws classic 12 px scrollbars, so a 500 px wide + # grid.html page lays out 488 px of content, as stock Firefox does. + + - test: tests/async/test_element_handle.py::test_bounding_box + reason: >- + grid.html in a 500x500 viewport: beside a 12 px classic scrollbar nine + 50 px boxes fit per row, not ten, so box 13 is at x=150 rather than the + x=100 upstream measures with no scrollbar width. + + - test: tests/sync/test_element_handle.py::test_bounding_box + reason: >- + The sync twin of tests/async/test_element_handle.py::test_bounding_box: + box 13 is at x=150 beside a classic scrollbar, not 100. + + - test: tests/async/test_locators.py::test_locators_should_return_bounding_box + reason: >- + The same grid.html measurement through a locator: x=150 beside a 12 px + classic scrollbar, not the 100 upstream measures with none. + + - test: tests/sync/test_locators.py::test_locators_should_return_bounding_box + reason: >- + The sync twin of the locator bounding-box test: x=150 beside a classic + scrollbar, not 100. + + - test: tests/async/test_screenshot.py::test_should_screenshot_with_mask + reason: >- + Compares a 500x500 grid.html screenshot with a golden image that has no + scrollbar. Here the page draws the classic scrollbar stock Firefox draws, + and that strip differs (5339 pixels). diff --git a/tests/patches/viewport-no-rdm.py b/tests/patches/viewport-no-rdm.py new file mode 100644 index 000000000..4d16681fb --- /dev/null +++ b/tests/patches/viewport-no-rdm.py @@ -0,0 +1,89 @@ +r""" +An emulated viewport must not put the page in Responsive Design Mode. + +Juggler used to set `browsingContext.inRDMPane = true` for every page with a +viewport, which is the default for new_context(). RDM is devtools' mobile mode: +Gecko draws content with the Android theme and overlay scrollbars +(nsPresContext::UseOverlayScrollbars), and stock getters take RDM early returns +(nsScreen, Navigator::MaxTouchPoints). A page can read both. With classic +scrollbars pinned, a desktop identity measured 12 px of scrollbar on a +launch-level page and 0 px in any context with a viewport. + +Playwright's Juggler now enables RDM only for `isMobile` +(microsoft/playwright#41859), and so does Camoufox's. The guard pins classic +scrollbars and measures the scrollbar gutter three ways: + + - a launch-level page, the control: it must be non-zero, or the check is + vacuous; + - a context with a viewport, which must match the control; + - a context with a viewport and is_mobile=True, which must still get RDM's + overlay scrollbars, so the option the caller asked for is not dropped. + + python tests/patches/viewport-no-rdm.py +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from helpers import resolve_binary # noqa: E402 + +VIEWPORT = {"width": 1000, "height": 600} + +PAGE = """
""" + +MEASURE = """() => { + const b = document.getElementById('b'); + return { + page: innerWidth - document.documentElement.clientWidth, + box: b.offsetWidth - b.clientWidth, + viewport: [innerWidth, innerHeight], + }; +}""" + + +def measure(page) -> dict: + page.set_content(PAGE) + return page.evaluate(MEASURE) + + +def main() -> int: + from camoufox.sync_api import Camoufox + + binary = resolve_binary() + with Camoufox(os="linux", headless=True, executable_path=str(binary), + firefox_user_prefs={"ui.useOverlayScrollbars": 0}, + i_know_what_im_doing=True) as browser: + control = measure(browser.new_page(no_viewport=True)) + emulated = measure(browser.new_context(viewport=VIEWPORT).new_page()) + mobile = measure(browser.new_context(viewport=VIEWPORT, is_mobile=True).new_page()) + + print(f" launch page: {control}") + print(f" viewport context: {emulated}") + print(f" is_mobile: {mobile}") + + failures = [] + if control["page"] <= 0 or control["box"] <= 0: + failures.append(f"launch page shows no classic scrollbar ({control}): the check is vacuous") + if emulated["viewport"] != [VIEWPORT["width"], VIEWPORT["height"]]: + failures.append(f"viewport {VIEWPORT} not applied: page reports {emulated['viewport']}") + if (emulated["page"], emulated["box"]) != (control["page"], control["box"]): + failures.append( + f"scrollbar width differs with a viewport: {emulated['page']}/{emulated['box']} px " + f"vs {control['page']}/{control['box']} px without (Responsive Design Mode)") + if (mobile["page"], mobile["box"]) != (0, 0): + failures.append( + f"is_mobile=True did not enable Responsive Design Mode: scrollbars measure " + f"{mobile['page']}/{mobile['box']} px, not RDM's 0/0 overlay") + + for f in failures: + print(f"FAIL: {f}") + if failures: + return 1 + print("PASS: a viewport keeps the platform's scrollbars; only is_mobile enables Responsive Design Mode.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From c2817c1caedc00257d85b054a282f25c92960eb1 Mon Sep 17 00:00:00 2001 From: coffeegrind123 <103264516+coffeegrind123@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:24:52 +0300 Subject: [PATCH 05/12] fix(coherence): let macOS draw Intel Mac GPUs, checked as machines coherence rejected "Intel(R) HD Graphics 400" and "Radeon R9 200 Series" on macOS as a Braswell Atom IGP and a desktop PC card. They are Firefox's sanitized buckets, not devices: SanitizeRenderer (FIREFOX_152_0_4_RELEASE) maps an Intel Mac's UHD 630 to the first and a Radeon Pro 5300M to the second, per Firefox's own TestCiMac and TestMacAmd. fpgen's pinned model records both from Firefox on macOS at 1.14% each. The rule kept every generated Mac off them, removed 24 real macOS presets in #779 (restored here, 9 + 15), and dropped either GPU from a caller's Mac preset. ANGLE and llvmpipe stay rejected: Firefox 152 has no ANGLE-on-Metal path (Bug 2046027 came later). Intel Macs are now checked as machines instead (intel-mac-hardware), for every non-Apple GPU on macOS: - a core count some Intel Mac with that GPU reports. Firefox reports physical cores where kern.tcsm_available is set and logical ones otherwise, so either counts: 2-8 physical / 4-16 logical for the IGP, up to the 2019 Mac Pro for a discrete GPU; - a screen that is not a notched MacBook's or the 24" iMac's. 45.5% of fpgen's Firefox macOS screens are one, and the draw already put an Intel or AMD bucket behind 4.9% of them. The WebGL draw applies the same check, given the core count. The preset GPU drop moves next to the WebGL draw, after the host core count and the display clamp: before, a preset's GPU was judged against cores the launch then replaced. The TypeScript launcher gets the same changes, and the goldens cover the new check and the narrowed draws. Co-Authored-By: Claude Opus 5.5 (1M context) (cherry picked from commit d6e2adcb6cf352756dc336f426e09b76454c88f5) --- docs/per-context-patches.md | 10 +- pythonlib/camoufox/coherence.py | 105 +- .../camoufox/fingerprint-presets-v150.json | 2993 +++++++++++++---- pythonlib/camoufox/fingerprint-presets.json | 1319 +++++++- pythonlib/camoufox/fingerprints.py | 3 +- pythonlib/camoufox/utils.py | 10 +- pythonlib/camoufox/webgl.py | 8 +- pythonlib/tests/test_coherence.py | 148 +- pythonlib/tests/test_shipped_data.py | 4 +- typescript/scripts/golden/identity_golden.py | 24 +- typescript/src/coherence.ts | 116 +- typescript/src/fingerprints.ts | 2 + typescript/src/utils.ts | 9 +- typescript/src/webgl.ts | 7 +- typescript/tests/coherence.test.ts | 103 +- typescript/tests/identity-golden.test.ts | 31 +- typescript/tests/launch.test.ts | 30 + typescript/tests/webgl.test.ts | 29 + 18 files changed, 4141 insertions(+), 810 deletions(-) diff --git a/docs/per-context-patches.md b/docs/per-context-patches.md index 21b8f5ef8..0b803a860 100644 --- a/docs/per-context-patches.md +++ b/docs/per-context-patches.md @@ -583,9 +583,9 @@ The Camoufox Python package (`pythonlib/`) generates fingerprints for both `NewB **Recommended for v149+ binaries:** opt into bundled real fingerprints via `fingerprint_preset=True`. The library auto-selects the v150 preset bundle -(`fingerprint-presets-v150.json`, 288 real fingerprints scraped from v149–v152 +(`fingerprint-presets-v150.json`, 285 real fingerprints scraped from v149–v152 browsers) for any binary at Firefox ≥ 149, and falls back to the original -bundle (`fingerprint-presets.json`, 109 presets) for older binaries. UA strings +bundle (`fingerprint-presets.json`, 113 presets) for older binaries. UA strings are rewritten to match the active binary's Firefox version, so opting in costs nothing for compatibility. @@ -612,7 +612,7 @@ bundles are shipped in the wheel. |----------|--------|-------| | UA, platform, HWC, oscpu | fpgen or preset | UA version patched to the browser's Firefox version (NewContext reads it from Playwright's `browser.version` unless `ff_version` is given) | | Screen dims, colorDepth | fpgen or preset | Viewport adjusted by -28px for browser chrome | -| WebGL vendor/renderer | Preset, or `sample_webgl_for_screen()` in `webgl.py` | A generated identity draws a GPU weighted by fpgen's share of Firefox on the OS, never a software rasteriser or a discrete GPU behind a netbook screen. `launch_options()` adds that GPU's recorded parameters, extensions and shader precisions from fpgen (`webgl_for_gpu()`), WebGL2 from the same device as WebGL1. | +| WebGL vendor/renderer | Preset, or `sample_webgl_for_screen()` in `webgl.py` | A generated identity draws a GPU weighted by fpgen's share of Firefox on the OS, never a software rasteriser, a discrete GPU behind a netbook screen, or an Intel Mac GPU beside a core count or a notched panel no Intel Mac has (`coherence.gpu_fits_machine()`). `launch_options()` adds that GPU's recorded parameters, extensions and shader precisions from fpgen (`webgl_for_gpu()`), WebGL2 from the same device as WebGL1. | | Font list | `_generate_random_font_subset()` | One weighted OS-version base in full, plus each addition unit at its measured probability; marker fonts always included. See [FONTS.md](FONTS.md). NOT from presets. | | Audio seed | Derived from the identity (NewBrowser) or `randint(1, 2^32-1)` (NewContext) | Never 0 | | Timezone | From preset, or `timezone` in `CAMOU_CONFIG` | The init script calls `setTimezone()` only for an explicit value; otherwise the C++ side falls back to `CAMOU_CONFIG` (set from geoip at launch) or the browser default. | @@ -637,9 +637,9 @@ bundles are shipped in the wheel. - WebGL drawn via the same `webgl.py` functions - Config validated against `properties.json` before serialization -**`fingerprint-presets.json`** — Original bundled real fingerprints organized by OS (macOS 18, Windows 73, Linux 18). Each preset includes navigator properties, screen dimensions, the WebGL vendor/renderer, and speech voices. Used for Firefox < 149 binaries. Font and voice data not used from presets — generated fresh per launch. +**`fingerprint-presets.json`** — Original bundled real fingerprints organized by OS (macOS 25, Windows 71, Linux 17). Each preset includes navigator properties, screen dimensions, the WebGL vendor/renderer, and speech voices. Used for Firefox < 149 binaries. Font and voice data not used from presets — generated fresh per launch. -**`fingerprint-presets-v150.json`** — Newer bundle covering Firefox v149–v152 (macOS 45, Windows 178, Linux 65; 288 total). Same schema as the original. Auto-selected by `load_presets()` when the active binary reports Firefox ≥ 149. +**`fingerprint-presets-v150.json`** — Newer bundle covering Firefox v149–v152 (macOS 58, Windows 168, Linux 59; 285 total). Same schema as the original. Auto-selected by `load_presets()` when the active binary reports Firefox ≥ 149. **`fonts.json`, `font-bases.json`, `font-groups.json`** — OS font lists, the OS-version bases and the addition units with their probabilities (see [FONTS.md](FONTS.md)). diff --git a/pythonlib/camoufox/coherence.py b/pythonlib/camoufox/coherence.py index 2708fac93..b112254b4 100644 --- a/pythonlib/camoufox/coherence.py +++ b/pythonlib/camoufox/coherence.py @@ -4,9 +4,9 @@ from the fingerprint generator, the GPU from fpgen's WebGL records, fonts and voices from its own catalogues, media devices from `media-devices.json`. Each pool is sampled on its own, so a combination that no machine has ever had can be built -out of individually plausible parts: an Apple M1 with 2 cores, a Mac reporting a -Braswell Atom GPU, a Linux identity whose platform says armv81 while its user -agent says x86_64. +out of individually plausible parts: an Apple M1 with 2 cores, an Intel Mac GPU +behind a notched Apple Silicon panel, a Linux identity whose platform says +armv81 while its user agent says x86_64. That class of defect cannot be removed by cleaning the pools, because it is created when they are combined. So every identity passes through here, whatever @@ -81,12 +81,35 @@ # the bottom 2 rows dead, which the boundary-sweep guard caught. BROWSER_CHROME_HEIGHT = 86 -# GPU strings that are not possible on macOS. Firefox on a Mac reports Apple -# Silicon as "Apple M1, or similar", and Intel Macs as an Intel Iris/UHD/HD -# 4000-6000 part; ANGLE is Windows-only (Direct3D), and the other two, which -# fpgen records from macOS, are a Braswell Atom IGP and a desktop PC card, -# neither of which shipped in any Mac. -_NOT_A_MAC_GPU = ('ANGLE', 'Intel(R) HD Graphics 400', 'Radeon R9 200 Series', 'llvmpipe') +# GPU strings that are not possible on macOS. Firefox 152 renders WebGL on a Mac +# through CGL, not ANGLE: its SanitizeRenderer has no ANGLE-on-Metal branch +# (Bug 2046027 added one after 152). +# +# Every other string a Mac reports is a bucket, not a device. SanitizeRenderer +# maps an Intel Mac's UHD 630 or Iris Plus 655 to "Intel(R) HD Graphics 400" and +# a Radeon Pro 5300M to "Radeon R9 200 Series" (Firefox's TestCiMac and +# TestMacAmd cases), so neither belongs here. +_NOT_A_MAC_GPU = ('ANGLE', 'llvmpipe') + +# navigator.hardwareConcurrency on an Intel Mac. Firefox reports the physical +# count where kern.tcsm_available is set (CPUs that need the MDS workaround) and +# the logical count otherwise (RuntimeService::ClampedHardwareConcurrency), so +# either is real. Macs running WebGL on the Intel IGP have 2-8 physical cores, +# 4-16 logical. +INTEL_MAC_IGP_CORES = frozenset({2, 4, 6, 8, 12, 16}) +# A discrete GPU adds the desktops, up to the 2019 Mac Pro's 28 cores (56 +# threads). An eGPU puts one behind any Intel Mac, so the IGP counts stay in. +INTEL_MAC_DGPU_CORES = INTEL_MAC_IGP_CORES | frozenset({10, 14, 18, 20, 24, 28, 32, 36, 48, 56}) + +# The notched MacBook panels' scaled resolutions (Air 13.6"/15.3", Pro 14"/16") +# and the 24" iMac's default. Only Apple Silicon Macs have them. +APPLE_SILICON_PANELS = frozenset({ + (1024, 665), (1280, 832), (1470, 956), (1710, 1112), # Air 13.6" + (1280, 828), (1440, 932), (1710, 1107), # Air 15.3" + (1147, 745), (1352, 878), (1512, 982), (1800, 1169), # Pro 14" + (1312, 848), (1496, 967), (1728, 1117), (2056, 1329), # Pro 16" + (2240, 1260), # iMac 24" +}) class Violation(NamedTuple): @@ -148,6 +171,41 @@ def gpu_fits_os(renderer: Optional[str], target_os: str) -> bool: return True +def _intel_mac_misfit(renderer: Optional[str], cores: Any, width: Any, height: Any) -> Optional[str]: + """Why no Intel Mac reports this GPU beside these cores and this screen.""" + renderer = str(renderer or '') + if not renderer or 'Apple M' in renderer: + return None + allowed = INTEL_MAC_IGP_CORES if 'Intel' in renderer else INTEL_MAC_DGPU_CORES + if isinstance(cores, int) and cores not in allowed: + return f'{renderer!r} with hardwareConcurrency {cores}; no Intel Mac with that GPU reports it' + if (width, height) in APPLE_SILICON_PANELS: + return f'{renderer!r} behind a {width}x{height} panel, which only Apple Silicon Macs have' + return None + + +def gpu_fits_machine( + renderer: Optional[str], target_os: str, cores: Any = None, width: Any = None, height: Any = None +) -> bool: + """Whether a machine with this GPU has these cores and this screen. + + Only Intel Macs are modelled. Like gpu_fits_os, which it does not repeat, + it serves both the WebGL draw and the finished-identity check. + """ + return target_os != 'mac' or _intel_mac_misfit(renderer, cores, width, height) is None + + +def _check_intel_mac_hardware(config: Dict[str, Any], target_os: str) -> Optional[str]: + if target_os != 'mac': + return None + return _intel_mac_misfit( + _renderer(config), + config.get('navigator.hardwareConcurrency'), + config.get('screen.width'), + config.get('screen.height'), + ) + + def _check_gpu_matches_os(config: Dict[str, Any], target_os: str) -> Optional[str]: renderer = _renderer(config) if not renderer or gpu_fits_os(renderer, target_os): @@ -290,6 +348,7 @@ def _check_arch_agreement(config: Dict[str, Any], target_os: str) -> Optional[st RULES: List[Rule] = [ Rule('apple-silicon-cores', _check_apple_silicon_cores, _repair_apple_silicon_cores), Rule('gpu-matches-os', _check_gpu_matches_os, None), + Rule('intel-mac-hardware', _check_intel_mac_hardware, None), Rule('color-depth', _check_color_depth, _repair_color_depth), Rule('touch-points', _check_touch_points, _repair_touch_points), Rule('device-pixel-ratio', _check_device_pixel_ratio, _repair_device_pixel_ratio), @@ -327,22 +386,28 @@ def repair_screen_orientation(config: Dict[str, Any]) -> bool: def drop_incoherent_source_values(config: Dict[str, Any], target_os: str) -> List[Violation]: """Discard values a source supplied that this identity cannot keep. - Run before the pools that would otherwise defer to them. A preset carries - its own GPU pair, so a macOS preset naming a Braswell Atom IGP keeps it all - the way to the page unless the pair is dropped here -- at which point the - normal WebGL sampling draws a coherent one instead. 15 of the 312 bundled - presets need this; they are scraped rows, not real machines. + Run after the core count and the screen are final, and before the pools + that would otherwise defer to them. A preset carries its own GPU pair, so a + macOS preset naming an Intel IGP keeps it all the way to the page unless + the pair is dropped here -- including when the launch has replaced the + preset's core count with a 20-core host's -- at which point the normal + WebGL sampling draws a coherent one instead. Only values that another pool can replace are dropped. Everything else is left for `apply()` to repair or report. """ - dropped = [] renderer = _renderer(config) - if renderer and not gpu_fits_os(renderer, target_os): - config.pop('webGl:renderer', None) - config.pop('webGl:vendor', None) - dropped.append(Violation('gpu-matches-os', f'dropped {renderer!r} for a {target_os} identity')) - return dropped + if not renderer: + return [] + if not gpu_fits_os(renderer, target_os): + rule = 'gpu-matches-os' + elif _check_intel_mac_hardware(config, target_os): + rule = 'intel-mac-hardware' + else: + return [] + config.pop('webGl:renderer', None) + config.pop('webGl:vendor', None) + return [Violation(rule, f'dropped {renderer!r} for a {target_os} identity')] def validate(config: Dict[str, Any], target_os: str) -> List[Violation]: diff --git a/pythonlib/camoufox/fingerprint-presets-v150.json b/pythonlib/camoufox/fingerprint-presets-v150.json index c0557052f..5ac7c07ef 100644 --- a/pythonlib/camoufox/fingerprint-presets-v150.json +++ b/pythonlib/camoufox/fingerprint-presets-v150.json @@ -25,6 +25,26 @@ "unmaskedRenderer": "Radeon HD 3200 Graphics, or similar" } }, + { + "navigator": { + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:150.0) Gecko/20100101 Firefox/150.0", + "platform": "MacIntel", + "hardwareConcurrency": 2, + "maxTouchPoints": 0 + }, + "screen": { + "width": 1280, + "height": 800, + "colorDepth": 30, + "availWidth": 1280, + "availHeight": 800, + "devicePixelRatio": 2 + }, + "webgl": { + "unmaskedVendor": "Intel Inc.", + "unmaskedRenderer": "Intel(R) HD Graphics 400, or similar" + } + }, { "navigator": { "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:150.0) Gecko/20100101 Firefox/150.0", @@ -703,6 +723,76 @@ "Zuzana:cs-CZ:local" ] }, + { + "navigator": { + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:149.0) Gecko/20100101 Firefox/149.0", + "platform": "MacIntel", + "hardwareConcurrency": 2, + "maxTouchPoints": 0 + }, + "screen": { + "width": 1440, + "height": 900, + "colorDepth": 30, + "availWidth": 1440, + "availHeight": 900, + "devicePixelRatio": 1 + }, + "webgl": { + "unmaskedVendor": "Intel Inc.", + "unmaskedRenderer": "Intel(R) HD Graphics 400, or similar" + }, + "speechVoices": [ + "Alex:en-US:local", + "Alice:it-IT:local", + "Alva:sv-SE:local", + "Amelie:fr-CA:local", + "Anna:de-DE:local", + "Carmit:he-IL:local", + "Damayanti:id-ID:local", + "Daniel:en-GB:local", + "Diego:es-AR:local", + "Ellen:nl-BE:local", + "Fiona:en-scotland:local", + "Fred:en-US:local", + "Ioana:ro-RO:local", + "Joana:pt-PT:local", + "Jorge:es-ES:local", + "Juan:es-MX:local", + "Kanya:th-TH:local", + "Karen:en-AU:local", + "Kyoko:ja-JP:local", + "Laura:sk-SK:local", + "Lekha:hi-IN:local", + "Luca:it-IT:local", + "Luciana:pt-BR:local", + "Maged:ar-SA:local", + "Mariska:hu-HU:local", + "Mei-Jia:zh-TW:local", + "Melina:el-GR:local", + "Milena:ru-RU:local", + "Moira:en-IE:local", + "Monica:es-ES:local", + "Nora:nb-NO:local", + "Paulina:es-MX:local", + "Rishi:en-IN:local", + "Samantha:en-US:local", + "Sara:da-DK:local", + "Satu:fi-FI:local", + "Sin-ji:zh-HK:local", + "Tessa:en-ZA:local", + "Thomas:fr-FR:local", + "Ting-Ting:zh-CN:local", + "Veena:en-IN:local", + "Victoria:en-US:local", + "Xander:nl-NL:local", + "Yelda:tr-TR:local", + "Yuna:ko-KR:local", + "Yuri:ru-RU:local", + "Zosia:pl-PL:local", + "Zuzana:cs-CZ:local" + ] + }, { "navigator": { "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:150.0) Gecko/20100101 Firefox/150.0", @@ -1112,31 +1202,28 @@ "navigator": { "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:150.0) Gecko/20100101 Firefox/150.0", "platform": "MacIntel", - "hardwareConcurrency": 10, + "hardwareConcurrency": 2, "maxTouchPoints": 0 }, "screen": { - "width": 1920, - "height": 1200, - "colorDepth": 30, - "availWidth": 1920, - "availHeight": 1170, - "devicePixelRatio": 1 + "width": 2560, + "height": 1440, + "colorDepth": 24, + "availWidth": 2560, + "availHeight": 1440, + "devicePixelRatio": 2 }, "webgl": { - "unmaskedVendor": "Apple", - "unmaskedRenderer": "Apple M1, or similar" + "unmaskedVendor": "ATI Technologies Inc.", + "unmaskedRenderer": "Radeon R9 200 Series, or similar" }, "speechVoices": [ "Albert:en-US:local", "Alice:it-IT:local", - "Soumya:kn-IN:local", "Alva:sv-SE:local", - "Aman:en-IN:local", "Amélie:fr-CA:local", "Amira:ms-MY:local", "Anna:de-DE:local", - "Aru:kk-KZ:local", "Bad News:en-US:local", "Bahh:en-US:local", "Bells:en-US:local", @@ -1178,7 +1265,6 @@ "Flo (Chinese (China mainland)):zh-CN:local", "Flo (Chinese (Taiwan)):zh-TW:local", "Fred:en-US:local", - "Geeta:te-IN:local", "Good News:en-US:local", "Grandma (German (Germany)):de-DE:local", "Grandma (English (UK)):en-GB:local", @@ -1232,10 +1318,8 @@ "Mónica:es-ES:local", "Montse:ca-ES:local", "Nora:nb-NO:local", - "Ona:lt-LT:local", "Organ:en-US:local", "Paulina:es-MX:local", - "Piya:bn-IN:local", "Superstar:en-US:local", "Ralph:en-US:local", "Reed (German (Germany)):de-DE:local", @@ -1298,7 +1382,6 @@ "Shelley (Chinese (China mainland)):zh-CN:local", "Shelley (Chinese (Taiwan)):zh-TW:local", "Sinji:zh-HK:local", - "Tara:en-IN:local", "Tessa:en-ZA:local", "Thomas:fr-FR:local", "Tina:sl-SI:local", @@ -1318,31 +1401,29 @@ "navigator": { "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:150.0) Gecko/20100101 Firefox/150.0", "platform": "MacIntel", - "hardwareConcurrency": 10, + "hardwareConcurrency": 16, "maxTouchPoints": 0 }, "screen": { - "width": 2048, - "height": 1152, + "width": 1344, + "height": 840, "colorDepth": 30, - "availWidth": 2048, - "availHeight": 1122, + "availWidth": 1344, + "availHeight": 764, "devicePixelRatio": 2 }, "webgl": { - "unmaskedVendor": "Apple", - "unmaskedRenderer": "Apple M1, or similar" + "unmaskedVendor": "Intel Inc.", + "unmaskedRenderer": "Intel(R) HD Graphics 400, or similar" }, "speechVoices": [ "Albert:en-US:local", "Alice:it-IT:local", "Soumya:kn-IN:local", "Alva:sv-SE:local", - "Aman:en-IN:local", "Amélie:fr-CA:local", "Amira:ms-MY:local", "Anna:de-DE:local", - "Aru:kk-KZ:local", "Bad News:en-US:local", "Bahh:en-US:local", "Bells:en-US:local", @@ -1438,7 +1519,6 @@ "Mónica:es-ES:local", "Montse:ca-ES:local", "Nora:nb-NO:local", - "Ona:lt-LT:local", "Organ:en-US:local", "Paulina:es-MX:local", "Piya:bn-IN:local", @@ -1504,7 +1584,6 @@ "Shelley (Chinese (China mainland)):zh-CN:local", "Shelley (Chinese (Taiwan)):zh-TW:local", "Sinji:zh-HK:local", - "Tara:en-IN:local", "Tessa:en-ZA:local", "Thomas:fr-FR:local", "Tina:sl-SI:local", @@ -1524,15 +1603,15 @@ "navigator": { "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:150.0) Gecko/20100101 Firefox/150.0", "platform": "MacIntel", - "hardwareConcurrency": 8, + "hardwareConcurrency": 10, "maxTouchPoints": 0 }, "screen": { - "width": 3440, - "height": 1440, + "width": 1920, + "height": 1200, "colorDepth": 30, - "availWidth": 3440, - "availHeight": 1364, + "availWidth": 1920, + "availHeight": 1170, "devicePixelRatio": 1 }, "webgl": { @@ -1734,12 +1813,12 @@ "maxTouchPoints": 0 }, "screen": { - "width": 1360, - "height": 768, + "width": 2048, + "height": 1152, "colorDepth": 30, - "availWidth": 1360, - "availHeight": 676, - "devicePixelRatio": 1 + "availWidth": 2048, + "availHeight": 1122, + "devicePixelRatio": 2 }, "webgl": { "unmaskedVendor": "Apple", @@ -1748,10 +1827,13 @@ "speechVoices": [ "Albert:en-US:local", "Alice:it-IT:local", + "Soumya:kn-IN:local", "Alva:sv-SE:local", + "Aman:en-IN:local", "Amélie:fr-CA:local", "Amira:ms-MY:local", "Anna:de-DE:local", + "Aru:kk-KZ:local", "Bad News:en-US:local", "Bahh:en-US:local", "Bells:en-US:local", @@ -1772,7 +1854,11 @@ "Eddy (French (Canada)):fr-CA:local", "Eddy (French (France)):fr-FR:local", "Eddy (Italian (Italy)):it-IT:local", + "Eddy (Japanese (Japan)):ja-JP:local", + "Eddy (Korean (South Korea)):ko-KR:local", "Eddy (Portuguese (Brazil)):pt-BR:local", + "Eddy (Chinese (China mainland)):zh-CN:local", + "Eddy (Chinese (Taiwan)):zh-TW:local", "Ellen:nl-BE:local", "Flo (German (Germany)):de-DE:local", "Flo (English (UK)):en-GB:local", @@ -1783,8 +1869,13 @@ "Flo (French (Canada)):fr-CA:local", "Flo (French (France)):fr-FR:local", "Flo (Italian (Italy)):it-IT:local", + "Flo (Japanese (Japan)):ja-JP:local", + "Flo (Korean (South Korea)):ko-KR:local", "Flo (Portuguese (Brazil)):pt-BR:local", + "Flo (Chinese (China mainland)):zh-CN:local", + "Flo (Chinese (Taiwan)):zh-TW:local", "Fred:en-US:local", + "Geeta:te-IN:local", "Good News:en-US:local", "Grandma (German (Germany)):de-DE:local", "Grandma (English (UK)):en-GB:local", @@ -1795,7 +1886,11 @@ "Grandma (French (Canada)):fr-CA:local", "Grandma (French (France)):fr-FR:local", "Grandma (Italian (Italy)):it-IT:local", + "Grandma (Japanese (Japan)):ja-JP:local", + "Grandma (Korean (South Korea)):ko-KR:local", "Grandma (Portuguese (Brazil)):pt-BR:local", + "Grandma (Chinese (China mainland)):zh-CN:local", + "Grandma (Chinese (Taiwan)):zh-TW:local", "Grandpa (German (Germany)):de-DE:local", "Grandpa (English (UK)):en-GB:local", "Grandpa (English (US)):en-US:local", @@ -1805,7 +1900,11 @@ "Grandpa (French (Canada)):fr-CA:local", "Grandpa (French (France)):fr-FR:local", "Grandpa (Italian (Italy)):it-IT:local", + "Grandpa (Japanese (Japan)):ja-JP:local", + "Grandpa (Korean (South Korea)):ko-KR:local", "Grandpa (Portuguese (Brazil)):pt-BR:local", + "Grandpa (Chinese (China mainland)):zh-CN:local", + "Grandpa (Chinese (Taiwan)):zh-TW:local", "Jester:en-US:local", "Ioana:ro-RO:local", "Jacques:fr-FR:local", @@ -1830,8 +1929,10 @@ "Mónica:es-ES:local", "Montse:ca-ES:local", "Nora:nb-NO:local", + "Ona:lt-LT:local", "Organ:en-US:local", "Paulina:es-MX:local", + "Piya:bn-IN:local", "Superstar:en-US:local", "Ralph:en-US:local", "Reed (German (Germany)):de-DE:local", @@ -1842,7 +1943,11 @@ "Reed (Finnish (Finland)):fi-FI:local", "Reed (French (Canada)):fr-CA:local", "Reed (Italian (Italy)):it-IT:local", + "Reed (Japanese (Japan)):ja-JP:local", + "Reed (Korean (South Korea)):ko-KR:local", "Reed (Portuguese (Brazil)):pt-BR:local", + "Reed (Chinese (China mainland)):zh-CN:local", + "Reed (Chinese (Taiwan)):zh-TW:local", "Rishi:en-IN:local", "Rocko (German (Germany)):de-DE:local", "Rocko (English (UK)):en-GB:local", @@ -1853,7 +1958,11 @@ "Rocko (French (Canada)):fr-CA:local", "Rocko (French (France)):fr-FR:local", "Rocko (Italian (Italy)):it-IT:local", + "Rocko (Japanese (Japan)):ja-JP:local", + "Rocko (Korean (South Korea)):ko-KR:local", "Rocko (Portuguese (Brazil)):pt-BR:local", + "Rocko (Chinese (China mainland)):zh-CN:local", + "Rocko (Chinese (Taiwan)):zh-TW:local", "Samantha:en-US:local", "Sandy (German (Germany)):de-DE:local", "Sandy (English (UK)):en-GB:local", @@ -1864,7 +1973,11 @@ "Sandy (French (Canada)):fr-CA:local", "Sandy (French (France)):fr-FR:local", "Sandy (Italian (Italy)):it-IT:local", + "Sandy (Japanese (Japan)):ja-JP:local", + "Sandy (Korean (South Korea)):ko-KR:local", "Sandy (Portuguese (Brazil)):pt-BR:local", + "Sandy (Chinese (China mainland)):zh-CN:local", + "Sandy (Chinese (Taiwan)):zh-TW:local", "Sara:da-DK:local", "Satu:fi-FI:local", "Shelley (German (Germany)):de-DE:local", @@ -1876,12 +1989,19 @@ "Shelley (French (Canada)):fr-CA:local", "Shelley (French (France)):fr-FR:local", "Shelley (Italian (Italy)):it-IT:local", + "Shelley (Japanese (Japan)):ja-JP:local", + "Shelley (Korean (South Korea)):ko-KR:local", "Shelley (Portuguese (Brazil)):pt-BR:local", + "Shelley (Chinese (China mainland)):zh-CN:local", + "Shelley (Chinese (Taiwan)):zh-TW:local", "Sinji:zh-HK:local", + "Tara:en-IN:local", "Tessa:en-ZA:local", "Thomas:fr-FR:local", + "Tina:sl-SI:local", "Tingting:zh-CN:local", "Trinoids:en-US:local", + "Vani:ta-IN:local", "Whisper:en-US:local", "Xander:nl-NL:local", "Yelda:tr-TR:local", @@ -1893,18 +2013,18 @@ }, { "navigator": { - "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:149.0) Gecko/20100101 Firefox/149.0", + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:150.0) Gecko/20100101 Firefox/150.0", "platform": "MacIntel", "hardwareConcurrency": 8, "maxTouchPoints": 0 }, "screen": { - "width": 1710, - "height": 1107, + "width": 3440, + "height": 1440, "colorDepth": 30, - "availWidth": 1710, - "availHeight": 1031, - "devicePixelRatio": 2 + "availWidth": 3440, + "availHeight": 1364, + "devicePixelRatio": 1 }, "webgl": { "unmaskedVendor": "Apple", @@ -2097,26 +2217,6 @@ "Zuzana:cs-CZ:local" ] }, - { - "navigator": { - "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:150.0) Gecko/20100101 Firefox/150.0", - "platform": "MacIntel", - "hardwareConcurrency": 2, - "maxTouchPoints": 0 - }, - "screen": { - "width": 1280, - "height": 800, - "colorDepth": 30, - "availWidth": 1280, - "availHeight": 715, - "devicePixelRatio": 1 - }, - "webgl": { - "unmaskedVendor": "Intel Inc.", - "unmaskedRenderer": "Intel(R) HD Graphics, or similar" - } - }, { "navigator": { "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:150.0) Gecko/20100101 Firefox/150.0", @@ -2125,12 +2225,12 @@ "maxTouchPoints": 0 }, "screen": { - "width": 1710, - "height": 1107, + "width": 1360, + "height": 768, "colorDepth": 30, - "availWidth": 1710, - "availHeight": 1074, - "devicePixelRatio": 2 + "availWidth": 1360, + "availHeight": 676, + "devicePixelRatio": 1 }, "webgl": { "unmaskedVendor": "Apple", @@ -2163,11 +2263,7 @@ "Eddy (French (Canada)):fr-CA:local", "Eddy (French (France)):fr-FR:local", "Eddy (Italian (Italy)):it-IT:local", - "Eddy (Japanese (Japan)):ja-JP:local", - "Eddy (Korean (South Korea)):ko-KR:local", "Eddy (Portuguese (Brazil)):pt-BR:local", - "Eddy (Chinese (China mainland)):zh-CN:local", - "Eddy (Chinese (Taiwan)):zh-TW:local", "Ellen:nl-BE:local", "Flo (German (Germany)):de-DE:local", "Flo (English (UK)):en-GB:local", @@ -2178,11 +2274,7 @@ "Flo (French (Canada)):fr-CA:local", "Flo (French (France)):fr-FR:local", "Flo (Italian (Italy)):it-IT:local", - "Flo (Japanese (Japan)):ja-JP:local", - "Flo (Korean (South Korea)):ko-KR:local", "Flo (Portuguese (Brazil)):pt-BR:local", - "Flo (Chinese (China mainland)):zh-CN:local", - "Flo (Chinese (Taiwan)):zh-TW:local", "Fred:en-US:local", "Good News:en-US:local", "Grandma (German (Germany)):de-DE:local", @@ -2194,11 +2286,7 @@ "Grandma (French (Canada)):fr-CA:local", "Grandma (French (France)):fr-FR:local", "Grandma (Italian (Italy)):it-IT:local", - "Grandma (Japanese (Japan)):ja-JP:local", - "Grandma (Korean (South Korea)):ko-KR:local", "Grandma (Portuguese (Brazil)):pt-BR:local", - "Grandma (Chinese (China mainland)):zh-CN:local", - "Grandma (Chinese (Taiwan)):zh-TW:local", "Grandpa (German (Germany)):de-DE:local", "Grandpa (English (UK)):en-GB:local", "Grandpa (English (US)):en-US:local", @@ -2208,11 +2296,7 @@ "Grandpa (French (Canada)):fr-CA:local", "Grandpa (French (France)):fr-FR:local", "Grandpa (Italian (Italy)):it-IT:local", - "Grandpa (Japanese (Japan)):ja-JP:local", - "Grandpa (Korean (South Korea)):ko-KR:local", "Grandpa (Portuguese (Brazil)):pt-BR:local", - "Grandpa (Chinese (China mainland)):zh-CN:local", - "Grandpa (Chinese (Taiwan)):zh-TW:local", "Jester:en-US:local", "Ioana:ro-RO:local", "Jacques:fr-FR:local", @@ -2249,11 +2333,7 @@ "Reed (Finnish (Finland)):fi-FI:local", "Reed (French (Canada)):fr-CA:local", "Reed (Italian (Italy)):it-IT:local", - "Reed (Japanese (Japan)):ja-JP:local", - "Reed (Korean (South Korea)):ko-KR:local", "Reed (Portuguese (Brazil)):pt-BR:local", - "Reed (Chinese (China mainland)):zh-CN:local", - "Reed (Chinese (Taiwan)):zh-TW:local", "Rishi:en-IN:local", "Rocko (German (Germany)):de-DE:local", "Rocko (English (UK)):en-GB:local", @@ -2264,11 +2344,7 @@ "Rocko (French (Canada)):fr-CA:local", "Rocko (French (France)):fr-FR:local", "Rocko (Italian (Italy)):it-IT:local", - "Rocko (Japanese (Japan)):ja-JP:local", - "Rocko (Korean (South Korea)):ko-KR:local", "Rocko (Portuguese (Brazil)):pt-BR:local", - "Rocko (Chinese (China mainland)):zh-CN:local", - "Rocko (Chinese (Taiwan)):zh-TW:local", "Samantha:en-US:local", "Sandy (German (Germany)):de-DE:local", "Sandy (English (UK)):en-GB:local", @@ -2279,11 +2355,7 @@ "Sandy (French (Canada)):fr-CA:local", "Sandy (French (France)):fr-FR:local", "Sandy (Italian (Italy)):it-IT:local", - "Sandy (Japanese (Japan)):ja-JP:local", - "Sandy (Korean (South Korea)):ko-KR:local", "Sandy (Portuguese (Brazil)):pt-BR:local", - "Sandy (Chinese (China mainland)):zh-CN:local", - "Sandy (Chinese (Taiwan)):zh-TW:local", "Sara:da-DK:local", "Satu:fi-FI:local", "Shelley (German (Germany)):de-DE:local", @@ -2295,15 +2367,10 @@ "Shelley (French (Canada)):fr-CA:local", "Shelley (French (France)):fr-FR:local", "Shelley (Italian (Italy)):it-IT:local", - "Shelley (Japanese (Japan)):ja-JP:local", - "Shelley (Korean (South Korea)):ko-KR:local", "Shelley (Portuguese (Brazil)):pt-BR:local", - "Shelley (Chinese (China mainland)):zh-CN:local", - "Shelley (Chinese (Taiwan)):zh-TW:local", "Sinji:zh-HK:local", "Tessa:en-ZA:local", "Thomas:fr-FR:local", - "Tina:sl-SI:local", "Tingting:zh-CN:local", "Trinoids:en-US:local", "Whisper:en-US:local", @@ -2317,17 +2384,17 @@ }, { "navigator": { - "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:150.0) Gecko/20100101 Firefox/150.0", + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:149.0) Gecko/20100101 Firefox/149.0", "platform": "MacIntel", "hardwareConcurrency": 8, "maxTouchPoints": 0 }, "screen": { - "width": 1280, - "height": 800, + "width": 1710, + "height": 1107, "colorDepth": 30, - "availWidth": 1280, - "availHeight": 730, + "availWidth": 1710, + "availHeight": 1031, "devicePixelRatio": 2 }, "webgl": { @@ -2337,10 +2404,13 @@ "speechVoices": [ "Albert:en-US:local", "Alice:it-IT:local", + "Soumya:kn-IN:local", "Alva:sv-SE:local", + "Aman:en-IN:local", "Amélie:fr-CA:local", "Amira:ms-MY:local", "Anna:de-DE:local", + "Aru:kk-KZ:local", "Bad News:en-US:local", "Bahh:en-US:local", "Bells:en-US:local", @@ -2361,7 +2431,11 @@ "Eddy (French (Canada)):fr-CA:local", "Eddy (French (France)):fr-FR:local", "Eddy (Italian (Italy)):it-IT:local", + "Eddy (Japanese (Japan)):ja-JP:local", + "Eddy (Korean (South Korea)):ko-KR:local", "Eddy (Portuguese (Brazil)):pt-BR:local", + "Eddy (Chinese (China mainland)):zh-CN:local", + "Eddy (Chinese (Taiwan)):zh-TW:local", "Ellen:nl-BE:local", "Flo (German (Germany)):de-DE:local", "Flo (English (UK)):en-GB:local", @@ -2372,8 +2446,13 @@ "Flo (French (Canada)):fr-CA:local", "Flo (French (France)):fr-FR:local", "Flo (Italian (Italy)):it-IT:local", + "Flo (Japanese (Japan)):ja-JP:local", + "Flo (Korean (South Korea)):ko-KR:local", "Flo (Portuguese (Brazil)):pt-BR:local", + "Flo (Chinese (China mainland)):zh-CN:local", + "Flo (Chinese (Taiwan)):zh-TW:local", "Fred:en-US:local", + "Geeta:te-IN:local", "Good News:en-US:local", "Grandma (German (Germany)):de-DE:local", "Grandma (English (UK)):en-GB:local", @@ -2384,7 +2463,11 @@ "Grandma (French (Canada)):fr-CA:local", "Grandma (French (France)):fr-FR:local", "Grandma (Italian (Italy)):it-IT:local", + "Grandma (Japanese (Japan)):ja-JP:local", + "Grandma (Korean (South Korea)):ko-KR:local", "Grandma (Portuguese (Brazil)):pt-BR:local", + "Grandma (Chinese (China mainland)):zh-CN:local", + "Grandma (Chinese (Taiwan)):zh-TW:local", "Grandpa (German (Germany)):de-DE:local", "Grandpa (English (UK)):en-GB:local", "Grandpa (English (US)):en-US:local", @@ -2394,7 +2477,11 @@ "Grandpa (French (Canada)):fr-CA:local", "Grandpa (French (France)):fr-FR:local", "Grandpa (Italian (Italy)):it-IT:local", + "Grandpa (Japanese (Japan)):ja-JP:local", + "Grandpa (Korean (South Korea)):ko-KR:local", "Grandpa (Portuguese (Brazil)):pt-BR:local", + "Grandpa (Chinese (China mainland)):zh-CN:local", + "Grandpa (Chinese (Taiwan)):zh-TW:local", "Jester:en-US:local", "Ioana:ro-RO:local", "Jacques:fr-FR:local", @@ -2419,8 +2506,10 @@ "Mónica:es-ES:local", "Montse:ca-ES:local", "Nora:nb-NO:local", + "Ona:lt-LT:local", "Organ:en-US:local", "Paulina:es-MX:local", + "Piya:bn-IN:local", "Superstar:en-US:local", "Ralph:en-US:local", "Reed (German (Germany)):de-DE:local", @@ -2431,7 +2520,11 @@ "Reed (Finnish (Finland)):fi-FI:local", "Reed (French (Canada)):fr-CA:local", "Reed (Italian (Italy)):it-IT:local", + "Reed (Japanese (Japan)):ja-JP:local", + "Reed (Korean (South Korea)):ko-KR:local", "Reed (Portuguese (Brazil)):pt-BR:local", + "Reed (Chinese (China mainland)):zh-CN:local", + "Reed (Chinese (Taiwan)):zh-TW:local", "Rishi:en-IN:local", "Rocko (German (Germany)):de-DE:local", "Rocko (English (UK)):en-GB:local", @@ -2442,7 +2535,11 @@ "Rocko (French (Canada)):fr-CA:local", "Rocko (French (France)):fr-FR:local", "Rocko (Italian (Italy)):it-IT:local", + "Rocko (Japanese (Japan)):ja-JP:local", + "Rocko (Korean (South Korea)):ko-KR:local", "Rocko (Portuguese (Brazil)):pt-BR:local", + "Rocko (Chinese (China mainland)):zh-CN:local", + "Rocko (Chinese (Taiwan)):zh-TW:local", "Samantha:en-US:local", "Sandy (German (Germany)):de-DE:local", "Sandy (English (UK)):en-GB:local", @@ -2453,7 +2550,11 @@ "Sandy (French (Canada)):fr-CA:local", "Sandy (French (France)):fr-FR:local", "Sandy (Italian (Italy)):it-IT:local", + "Sandy (Japanese (Japan)):ja-JP:local", + "Sandy (Korean (South Korea)):ko-KR:local", "Sandy (Portuguese (Brazil)):pt-BR:local", + "Sandy (Chinese (China mainland)):zh-CN:local", + "Sandy (Chinese (Taiwan)):zh-TW:local", "Sara:da-DK:local", "Satu:fi-FI:local", "Shelley (German (Germany)):de-DE:local", @@ -2465,12 +2566,19 @@ "Shelley (French (Canada)):fr-CA:local", "Shelley (French (France)):fr-FR:local", "Shelley (Italian (Italy)):it-IT:local", + "Shelley (Japanese (Japan)):ja-JP:local", + "Shelley (Korean (South Korea)):ko-KR:local", "Shelley (Portuguese (Brazil)):pt-BR:local", + "Shelley (Chinese (China mainland)):zh-CN:local", + "Shelley (Chinese (Taiwan)):zh-TW:local", "Sinji:zh-HK:local", + "Tara:en-IN:local", "Tessa:en-ZA:local", "Thomas:fr-FR:local", + "Tina:sl-SI:local", "Tingting:zh-CN:local", "Trinoids:en-US:local", + "Vani:ta-IN:local", "Whisper:en-US:local", "Xander:nl-NL:local", "Yelda:tr-TR:local", @@ -2484,25 +2592,44 @@ "navigator": { "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:150.0) Gecko/20100101 Firefox/150.0", "platform": "MacIntel", - "hardwareConcurrency": 8, + "hardwareConcurrency": 2, "maxTouchPoints": 0 }, "screen": { - "width": 1440, - "height": 900, + "width": 1280, + "height": 800, "colorDepth": 30, - "availWidth": 1440, - "availHeight": 870, - "devicePixelRatio": 2 + "availWidth": 1280, + "availHeight": 715, + "devicePixelRatio": 1 }, "webgl": { "unmaskedVendor": "Intel Inc.", "unmaskedRenderer": "Intel(R) HD Graphics, or similar" + } + }, + { + "navigator": { + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:150.0) Gecko/20100101 Firefox/150.0", + "platform": "MacIntel", + "hardwareConcurrency": 10, + "maxTouchPoints": 0 + }, + "screen": { + "width": 1710, + "height": 1107, + "colorDepth": 30, + "availWidth": 1710, + "availHeight": 1074, + "devicePixelRatio": 2 + }, + "webgl": { + "unmaskedVendor": "Apple", + "unmaskedRenderer": "Apple M1, or similar" }, "speechVoices": [ "Albert:en-US:local", "Alice:it-IT:local", - "Soumya:kn-IN:local", "Alva:sv-SE:local", "Amélie:fr-CA:local", "Amira:ms-MY:local", @@ -2548,7 +2675,6 @@ "Flo (Chinese (China mainland)):zh-CN:local", "Flo (Chinese (Taiwan)):zh-TW:local", "Fred:en-US:local", - "Geeta:te-IN:local", "Good News:en-US:local", "Grandma (German (Germany)):de-DE:local", "Grandma (English (UK)):en-GB:local", @@ -2604,7 +2730,6 @@ "Nora:nb-NO:local", "Organ:en-US:local", "Paulina:es-MX:local", - "Piya:bn-IN:local", "Superstar:en-US:local", "Ralph:en-US:local", "Reed (German (Germany)):de-DE:local", @@ -2672,7 +2797,6 @@ "Tina:sl-SI:local", "Tingting:zh-CN:local", "Trinoids:en-US:local", - "Vani:ta-IN:local", "Whisper:en-US:local", "Xander:nl-NL:local", "Yelda:tr-TR:local", @@ -2686,20 +2810,20 @@ "navigator": { "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:150.0) Gecko/20100101 Firefox/150.0", "platform": "MacIntel", - "hardwareConcurrency": 6, + "hardwareConcurrency": 8, "maxTouchPoints": 0 }, "screen": { - "width": 1792, - "height": 1120, + "width": 1280, + "height": 800, "colorDepth": 30, - "availWidth": 1792, - "availHeight": 1010, + "availWidth": 1280, + "availHeight": 730, "devicePixelRatio": 2 }, "webgl": { - "unmaskedVendor": "ATI Technologies Inc.", - "unmaskedRenderer": "Radeon HD 3200 Graphics, or similar" + "unmaskedVendor": "Apple", + "unmaskedRenderer": "Apple M1, or similar" }, "speechVoices": [ "Albert:en-US:local", @@ -2728,11 +2852,7 @@ "Eddy (French (Canada)):fr-CA:local", "Eddy (French (France)):fr-FR:local", "Eddy (Italian (Italy)):it-IT:local", - "Eddy (Japanese (Japan)):ja-JP:local", - "Eddy (Korean (South Korea)):ko-KR:local", "Eddy (Portuguese (Brazil)):pt-BR:local", - "Eddy (Chinese (China mainland)):zh-CN:local", - "Eddy (Chinese (Taiwan)):zh-TW:local", "Ellen:nl-BE:local", "Flo (German (Germany)):de-DE:local", "Flo (English (UK)):en-GB:local", @@ -2743,11 +2863,7 @@ "Flo (French (Canada)):fr-CA:local", "Flo (French (France)):fr-FR:local", "Flo (Italian (Italy)):it-IT:local", - "Flo (Japanese (Japan)):ja-JP:local", - "Flo (Korean (South Korea)):ko-KR:local", "Flo (Portuguese (Brazil)):pt-BR:local", - "Flo (Chinese (China mainland)):zh-CN:local", - "Flo (Chinese (Taiwan)):zh-TW:local", "Fred:en-US:local", "Good News:en-US:local", "Grandma (German (Germany)):de-DE:local", @@ -2759,11 +2875,7 @@ "Grandma (French (Canada)):fr-CA:local", "Grandma (French (France)):fr-FR:local", "Grandma (Italian (Italy)):it-IT:local", - "Grandma (Japanese (Japan)):ja-JP:local", - "Grandma (Korean (South Korea)):ko-KR:local", "Grandma (Portuguese (Brazil)):pt-BR:local", - "Grandma (Chinese (China mainland)):zh-CN:local", - "Grandma (Chinese (Taiwan)):zh-TW:local", "Grandpa (German (Germany)):de-DE:local", "Grandpa (English (UK)):en-GB:local", "Grandpa (English (US)):en-US:local", @@ -2773,11 +2885,7 @@ "Grandpa (French (Canada)):fr-CA:local", "Grandpa (French (France)):fr-FR:local", "Grandpa (Italian (Italy)):it-IT:local", - "Grandpa (Japanese (Japan)):ja-JP:local", - "Grandpa (Korean (South Korea)):ko-KR:local", "Grandpa (Portuguese (Brazil)):pt-BR:local", - "Grandpa (Chinese (China mainland)):zh-CN:local", - "Grandpa (Chinese (Taiwan)):zh-TW:local", "Jester:en-US:local", "Ioana:ro-RO:local", "Jacques:fr-FR:local", @@ -2814,11 +2922,7 @@ "Reed (Finnish (Finland)):fi-FI:local", "Reed (French (Canada)):fr-CA:local", "Reed (Italian (Italy)):it-IT:local", - "Reed (Japanese (Japan)):ja-JP:local", - "Reed (Korean (South Korea)):ko-KR:local", "Reed (Portuguese (Brazil)):pt-BR:local", - "Reed (Chinese (China mainland)):zh-CN:local", - "Reed (Chinese (Taiwan)):zh-TW:local", "Rishi:en-IN:local", "Rocko (German (Germany)):de-DE:local", "Rocko (English (UK)):en-GB:local", @@ -2829,11 +2933,7 @@ "Rocko (French (Canada)):fr-CA:local", "Rocko (French (France)):fr-FR:local", "Rocko (Italian (Italy)):it-IT:local", - "Rocko (Japanese (Japan)):ja-JP:local", - "Rocko (Korean (South Korea)):ko-KR:local", "Rocko (Portuguese (Brazil)):pt-BR:local", - "Rocko (Chinese (China mainland)):zh-CN:local", - "Rocko (Chinese (Taiwan)):zh-TW:local", "Samantha:en-US:local", "Sandy (German (Germany)):de-DE:local", "Sandy (English (UK)):en-GB:local", @@ -2844,11 +2944,7 @@ "Sandy (French (Canada)):fr-CA:local", "Sandy (French (France)):fr-FR:local", "Sandy (Italian (Italy)):it-IT:local", - "Sandy (Japanese (Japan)):ja-JP:local", - "Sandy (Korean (South Korea)):ko-KR:local", "Sandy (Portuguese (Brazil)):pt-BR:local", - "Sandy (Chinese (China mainland)):zh-CN:local", - "Sandy (Chinese (Taiwan)):zh-TW:local", "Sara:da-DK:local", "Satu:fi-FI:local", "Shelley (German (Germany)):de-DE:local", @@ -2860,18 +2956,12 @@ "Shelley (French (Canada)):fr-CA:local", "Shelley (French (France)):fr-FR:local", "Shelley (Italian (Italy)):it-IT:local", - "Shelley (Japanese (Japan)):ja-JP:local", - "Shelley (Korean (South Korea)):ko-KR:local", "Shelley (Portuguese (Brazil)):pt-BR:local", - "Shelley (Chinese (China mainland)):zh-CN:local", - "Shelley (Chinese (Taiwan)):zh-TW:local", "Sinji:zh-HK:local", "Tessa:en-ZA:local", "Thomas:fr-FR:local", - "Tina:sl-SI:local", "Tingting:zh-CN:local", "Trinoids:en-US:local", - "Vani:ta-IN:local", "Whisper:en-US:local", "Xander:nl-NL:local", "Yelda:tr-TR:local", @@ -2881,26 +2971,6 @@ "Zuzana:cs-CZ:local" ] }, - { - "navigator": { - "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:150.0) Gecko/20100101 Firefox/150.0", - "platform": "MacIntel", - "hardwareConcurrency": 10, - "maxTouchPoints": 0 - }, - "screen": { - "width": 1512, - "height": 982, - "colorDepth": 30, - "availWidth": 1512, - "availHeight": 893, - "devicePixelRatio": 2 - }, - "webgl": { - "unmaskedVendor": "Apple", - "unmaskedRenderer": "Apple M1, or similar" - } - }, { "navigator": { "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:150.0) Gecko/20100101 Firefox/150.0", @@ -2909,27 +2979,25 @@ "maxTouchPoints": 0 }, "screen": { - "width": 1470, - "height": 956, + "width": 1440, + "height": 900, "colorDepth": 30, - "availWidth": 1470, - "availHeight": 880, + "availWidth": 1440, + "availHeight": 870, "devicePixelRatio": 2 }, "webgl": { - "unmaskedVendor": "Apple", - "unmaskedRenderer": "Apple M1, or similar" + "unmaskedVendor": "Intel Inc.", + "unmaskedRenderer": "Intel(R) HD Graphics, or similar" }, "speechVoices": [ "Albert:en-US:local", "Alice:it-IT:local", "Soumya:kn-IN:local", "Alva:sv-SE:local", - "Aman:en-IN:local", "Amélie:fr-CA:local", "Amira:ms-MY:local", "Anna:de-DE:local", - "Aru:kk-KZ:local", "Bad News:en-US:local", "Bahh:en-US:local", "Bells:en-US:local", @@ -3025,7 +3093,6 @@ "Mónica:es-ES:local", "Montse:ca-ES:local", "Nora:nb-NO:local", - "Ona:lt-LT:local", "Organ:en-US:local", "Paulina:es-MX:local", "Piya:bn-IN:local", @@ -3091,7 +3158,6 @@ "Shelley (Chinese (China mainland)):zh-CN:local", "Shelley (Chinese (Taiwan)):zh-TW:local", "Sinji:zh-HK:local", - "Tara:en-IN:local", "Tessa:en-ZA:local", "Thomas:fr-FR:local", "Tina:sl-SI:local", @@ -3109,22 +3175,22 @@ }, { "navigator": { - "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:149.0) Gecko/20100101 Firefox/149.0", + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:150.0) Gecko/20100101 Firefox/150.0", "platform": "MacIntel", - "hardwareConcurrency": 8, + "hardwareConcurrency": 6, "maxTouchPoints": 0 }, "screen": { - "width": 1920, - "height": 1080, + "width": 1792, + "height": 1120, "colorDepth": 30, - "availWidth": 1920, - "availHeight": 1080, + "availWidth": 1792, + "availHeight": 1010, "devicePixelRatio": 2 }, "webgl": { - "unmaskedVendor": "Apple", - "unmaskedRenderer": "Apple M1, or similar" + "unmaskedVendor": "ATI Technologies Inc.", + "unmaskedRenderer": "Radeon HD 3200 Graphics, or similar" }, "speechVoices": [ "Albert:en-US:local", @@ -3308,33 +3374,30 @@ }, { "navigator": { - "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:149.0) Gecko/20100101 Firefox/149.0", + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:150.0) Gecko/20100101 Firefox/150.0", "platform": "MacIntel", - "hardwareConcurrency": 10, + "hardwareConcurrency": 6, "maxTouchPoints": 0 }, "screen": { - "width": 2560, - "height": 1600, + "width": 2048, + "height": 1152, "colorDepth": 30, - "availWidth": 2560, - "availHeight": 1480, - "devicePixelRatio": 1 + "availWidth": 2048, + "availHeight": 1033, + "devicePixelRatio": 2 }, "webgl": { - "unmaskedVendor": "Apple", - "unmaskedRenderer": "Apple M1, or similar" + "unmaskedVendor": "ATI Technologies Inc.", + "unmaskedRenderer": "Radeon R9 200 Series, or similar" }, "speechVoices": [ "Albert:en-US:local", "Alice:it-IT:local", - "Soumya:kn-IN:local", "Alva:sv-SE:local", - "Aman:en-IN:local", "Amélie:fr-CA:local", "Amira:ms-MY:local", "Anna:de-DE:local", - "Aru:kk-KZ:local", "Bad News:en-US:local", "Bahh:en-US:local", "Bells:en-US:local", @@ -3376,7 +3439,6 @@ "Flo (Chinese (China mainland)):zh-CN:local", "Flo (Chinese (Taiwan)):zh-TW:local", "Fred:en-US:local", - "Geeta:te-IN:local", "Good News:en-US:local", "Grandma (German (Germany)):de-DE:local", "Grandma (English (UK)):en-GB:local", @@ -3430,10 +3492,8 @@ "Mónica:es-ES:local", "Montse:ca-ES:local", "Nora:nb-NO:local", - "Ona:lt-LT:local", "Organ:en-US:local", "Paulina:es-MX:local", - "Piya:bn-IN:local", "Superstar:en-US:local", "Ralph:en-US:local", "Reed (German (Germany)):de-DE:local", @@ -3496,7 +3556,6 @@ "Shelley (Chinese (China mainland)):zh-CN:local", "Shelley (Chinese (Taiwan)):zh-TW:local", "Sinji:zh-HK:local", - "Tara:en-IN:local", "Tessa:en-ZA:local", "Thomas:fr-FR:local", "Tina:sl-SI:local", @@ -3514,17 +3573,37 @@ }, { "navigator": { - "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:149.0) Gecko/20100101 Firefox/149.0", + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:150.0) Gecko/20100101 Firefox/150.0", "platform": "MacIntel", - "hardwareConcurrency": 16, + "hardwareConcurrency": 10, "maxTouchPoints": 0 }, "screen": { - "width": 1728, - "height": 1117, + "width": 1512, + "height": 982, "colorDepth": 30, - "availWidth": 1728, - "availHeight": 1083, + "availWidth": 1512, + "availHeight": 893, + "devicePixelRatio": 2 + }, + "webgl": { + "unmaskedVendor": "Apple", + "unmaskedRenderer": "Apple M1, or similar" + } + }, + { + "navigator": { + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:150.0) Gecko/20100101 Firefox/150.0", + "platform": "MacIntel", + "hardwareConcurrency": 8, + "maxTouchPoints": 0 + }, + "screen": { + "width": 1470, + "height": 956, + "colorDepth": 30, + "availWidth": 1470, + "availHeight": 880, "devicePixelRatio": 2 }, "webgl": { @@ -3722,31 +3801,28 @@ "navigator": { "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:149.0) Gecko/20100101 Firefox/149.0", "platform": "MacIntel", - "hardwareConcurrency": 10, + "hardwareConcurrency": 2, "maxTouchPoints": 0 }, "screen": { - "width": 1280, - "height": 832, + "width": 1920, + "height": 1080, "colorDepth": 30, - "availWidth": 1280, - "availHeight": 803, - "devicePixelRatio": 2 + "availWidth": 1920, + "availHeight": 1055, + "devicePixelRatio": 1 }, "webgl": { - "unmaskedVendor": "Apple", - "unmaskedRenderer": "Apple M1, or similar" + "unmaskedVendor": "Intel Inc.", + "unmaskedRenderer": "Intel(R) HD Graphics 400, or similar" }, "speechVoices": [ "Albert:en-US:local", "Alice:it-IT:local", - "Soumya:kn-IN:local", "Alva:sv-SE:local", - "Aman:en-IN:local", "Amélie:fr-CA:local", "Amira:ms-MY:local", "Anna:de-DE:local", - "Aru:kk-KZ:local", "Malas noticias:en-US:local", "Bahh:en-US:local", "Campanas:en-US:local", @@ -3767,11 +3843,7 @@ "Eddy (Francés (Canadá)):fr-CA:local", "Eddy (Francés (Francia)):fr-FR:local", "Eddy (Italiano (Italia)):it-IT:local", - "Eddy (Japonés (Japón)):ja-JP:local", - "Eddy (Coreano (Corea del Sur)):ko-KR:local", "Eddy (Portugués (Brasil)):pt-BR:local", - "Eddy (Chino (China continental)):zh-CN:local", - "Eddy (Chino (Taiwán)):zh-TW:local", "Ellen:nl-BE:local", "Flo (Alemán (Alemania)):de-DE:local", "Flo (Inglés (RU)):en-GB:local", @@ -3782,13 +3854,8 @@ "Flo (Francés (Canadá)):fr-CA:local", "Flo (Francés (Francia)):fr-FR:local", "Flo (Italiano (Italia)):it-IT:local", - "Flo (Japonés (Japón)):ja-JP:local", - "Flo (Coreano (Corea del Sur)):ko-KR:local", "Flo (Portugués (Brasil)):pt-BR:local", - "Flo (Chino (China continental)):zh-CN:local", - "Flo (Chino (Taiwán)):zh-TW:local", "Fred:en-US:local", - "Geeta:te-IN:local", "Buenas noticias:en-US:local", "Grandma (Alemán (Alemania)):de-DE:local", "Grandma (Inglés (RU)):en-GB:local", @@ -3799,11 +3866,7 @@ "Grandma (Francés (Canadá)):fr-CA:local", "Grandma (Francés (Francia)):fr-FR:local", "Grandma (Italiano (Italia)):it-IT:local", - "Grandma (Japonés (Japón)):ja-JP:local", - "Grandma (Coreano (Corea del Sur)):ko-KR:local", "Grandma (Portugués (Brasil)):pt-BR:local", - "Grandma (Chino (China continental)):zh-CN:local", - "Grandma (Chino (Taiwán)):zh-TW:local", "Grandpa (Alemán (Alemania)):de-DE:local", "Grandpa (Inglés (RU)):en-GB:local", "Grandpa (Inglés (EE. UU.)):en-US:local", @@ -3813,11 +3876,7 @@ "Grandpa (Francés (Canadá)):fr-CA:local", "Grandpa (Francés (Francia)):fr-FR:local", "Grandpa (Italiano (Italia)):it-IT:local", - "Grandpa (Japonés (Japón)):ja-JP:local", - "Grandpa (Coreano (Corea del Sur)):ko-KR:local", "Grandpa (Portugués (Brasil)):pt-BR:local", - "Grandpa (Chino (China continental)):zh-CN:local", - "Grandpa (Chino (Taiwán)):zh-TW:local", "Bufón:en-US:local", "Ioana:ro-RO:local", "Jacques:fr-FR:local", @@ -3842,10 +3901,8 @@ "Mónica:es-ES:local", "Montse:ca-ES:local", "Nora:nb-NO:local", - "Ona:lt-LT:local", "Órgano:en-US:local", "Paulina:es-MX:local", - "Piya:bn-IN:local", "Superestrella:en-US:local", "Ralph:en-US:local", "Reed (Alemán (Alemania)):de-DE:local", @@ -3856,11 +3913,7 @@ "Reed (Finés (Finlandia)):fi-FI:local", "Reed (Francés (Canadá)):fr-CA:local", "Reed (Italiano (Italia)):it-IT:local", - "Reed (Japonés (Japón)):ja-JP:local", - "Reed (Coreano (Corea del Sur)):ko-KR:local", "Reed (Portugués (Brasil)):pt-BR:local", - "Reed (Chino (China continental)):zh-CN:local", - "Reed (Chino (Taiwán)):zh-TW:local", "Rishi:en-IN:local", "Rocko (Alemán (Alemania)):de-DE:local", "Rocko (Inglés (RU)):en-GB:local", @@ -3871,11 +3924,7 @@ "Rocko (Francés (Canadá)):fr-CA:local", "Rocko (Francés (Francia)):fr-FR:local", "Rocko (Italiano (Italia)):it-IT:local", - "Rocko (Japonés (Japón)):ja-JP:local", - "Rocko (Coreano (Corea del Sur)):ko-KR:local", "Rocko (Portugués (Brasil)):pt-BR:local", - "Rocko (Chino (China continental)):zh-CN:local", - "Rocko (Chino (Taiwán)):zh-TW:local", "Samantha:en-US:local", "Sandy (Alemán (Alemania)):de-DE:local", "Sandy (Inglés (RU)):en-GB:local", @@ -3886,11 +3935,7 @@ "Sandy (Francés (Canadá)):fr-CA:local", "Sandy (Francés (Francia)):fr-FR:local", "Sandy (Italiano (Italia)):it-IT:local", - "Sandy (Japonés (Japón)):ja-JP:local", - "Sandy (Coreano (Corea del Sur)):ko-KR:local", "Sandy (Portugués (Brasil)):pt-BR:local", - "Sandy (Chino (China continental)):zh-CN:local", - "Sandy (Chino (Taiwán)):zh-TW:local", "Sara:da-DK:local", "Satu:fi-FI:local", "Shelley (Alemán (Alemania)):de-DE:local", @@ -3902,19 +3947,12 @@ "Shelley (Francés (Canadá)):fr-CA:local", "Shelley (Francés (Francia)):fr-FR:local", "Shelley (Italiano (Italia)):it-IT:local", - "Shelley (Japonés (Japón)):ja-JP:local", - "Shelley (Coreano (Corea del Sur)):ko-KR:local", "Shelley (Portugués (Brasil)):pt-BR:local", - "Shelley (Chino (China continental)):zh-CN:local", - "Shelley (Chino (Taiwán)):zh-TW:local", "Sinji:zh-HK:local", - "Tara:en-IN:local", "Tessa:en-ZA:local", "Thomas:fr-FR:local", - "Tina:sl-SI:local", "Tingting:zh-CN:local", "Trinoids:en-US:local", - "Vani:ta-IN:local", "Susurro:en-US:local", "Xander:nl-NL:local", "Yelda:tr-TR:local", @@ -3926,17 +3964,17 @@ }, { "navigator": { - "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:150.0) Gecko/20100101 Firefox/150.0", + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:149.0) Gecko/20100101 Firefox/149.0", "platform": "MacIntel", "hardwareConcurrency": 8, "maxTouchPoints": 0 }, "screen": { - "width": 1680, - "height": 1050, + "width": 1920, + "height": 1080, "colorDepth": 30, - "availWidth": 1680, - "availHeight": 934, + "availWidth": 1920, + "availHeight": 1080, "devicePixelRatio": 2 }, "webgl": { @@ -3946,13 +3984,10 @@ "speechVoices": [ "Albert:en-US:local", "Alice:it-IT:local", - "Soumya:kn-IN:local", "Alva:sv-SE:local", - "Aman:en-IN:local", "Amélie:fr-CA:local", "Amira:ms-MY:local", "Anna:de-DE:local", - "Aru:kk-KZ:local", "Bad News:en-US:local", "Bahh:en-US:local", "Bells:en-US:local", @@ -3994,7 +4029,6 @@ "Flo (Chinese (China mainland)):zh-CN:local", "Flo (Chinese (Taiwan)):zh-TW:local", "Fred:en-US:local", - "Geeta:te-IN:local", "Good News:en-US:local", "Grandma (German (Germany)):de-DE:local", "Grandma (English (UK)):en-GB:local", @@ -4048,10 +4082,8 @@ "Mónica:es-ES:local", "Montse:ca-ES:local", "Nora:nb-NO:local", - "Ona:lt-LT:local", "Organ:en-US:local", "Paulina:es-MX:local", - "Piya:bn-IN:local", "Superstar:en-US:local", "Ralph:en-US:local", "Reed (German (Germany)):de-DE:local", @@ -4083,82 +4115,1938 @@ "Rocko (Chinese (China mainland)):zh-CN:local", "Rocko (Chinese (Taiwan)):zh-TW:local", "Samantha:en-US:local", - "Sandy (German (Germany)):de-DE:local", - "Sandy (English (UK)):en-GB:local", - "Sandy (English (US)):en-US:local", - "Sandy (Spanish (Spain)):es-ES:local", - "Sandy (Spanish (Mexico)):es-MX:local", - "Sandy (Finnish (Finland)):fi-FI:local", - "Sandy (French (Canada)):fr-CA:local", - "Sandy (French (France)):fr-FR:local", - "Sandy (Italian (Italy)):it-IT:local", - "Sandy (Japanese (Japan)):ja-JP:local", - "Sandy (Korean (South Korea)):ko-KR:local", - "Sandy (Portuguese (Brazil)):pt-BR:local", - "Sandy (Chinese (China mainland)):zh-CN:local", - "Sandy (Chinese (Taiwan)):zh-TW:local", + "Sandy (German (Germany)):de-DE:local", + "Sandy (English (UK)):en-GB:local", + "Sandy (English (US)):en-US:local", + "Sandy (Spanish (Spain)):es-ES:local", + "Sandy (Spanish (Mexico)):es-MX:local", + "Sandy (Finnish (Finland)):fi-FI:local", + "Sandy (French (Canada)):fr-CA:local", + "Sandy (French (France)):fr-FR:local", + "Sandy (Italian (Italy)):it-IT:local", + "Sandy (Japanese (Japan)):ja-JP:local", + "Sandy (Korean (South Korea)):ko-KR:local", + "Sandy (Portuguese (Brazil)):pt-BR:local", + "Sandy (Chinese (China mainland)):zh-CN:local", + "Sandy (Chinese (Taiwan)):zh-TW:local", + "Sara:da-DK:local", + "Satu:fi-FI:local", + "Shelley (German (Germany)):de-DE:local", + "Shelley (English (UK)):en-GB:local", + "Shelley (English (US)):en-US:local", + "Shelley (Spanish (Spain)):es-ES:local", + "Shelley (Spanish (Mexico)):es-MX:local", + "Shelley (Finnish (Finland)):fi-FI:local", + "Shelley (French (Canada)):fr-CA:local", + "Shelley (French (France)):fr-FR:local", + "Shelley (Italian (Italy)):it-IT:local", + "Shelley (Japanese (Japan)):ja-JP:local", + "Shelley (Korean (South Korea)):ko-KR:local", + "Shelley (Portuguese (Brazil)):pt-BR:local", + "Shelley (Chinese (China mainland)):zh-CN:local", + "Shelley (Chinese (Taiwan)):zh-TW:local", + "Sinji:zh-HK:local", + "Tessa:en-ZA:local", + "Thomas:fr-FR:local", + "Tina:sl-SI:local", + "Tingting:zh-CN:local", + "Trinoids:en-US:local", + "Vani:ta-IN:local", + "Whisper:en-US:local", + "Xander:nl-NL:local", + "Yelda:tr-TR:local", + "Yuna:ko-KR:local", + "Zarvox:en-US:local", + "Zosia:pl-PL:local", + "Zuzana:cs-CZ:local" + ] + }, + { + "navigator": { + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:149.0) Gecko/20100101 Firefox/149.0", + "platform": "MacIntel", + "hardwareConcurrency": 10, + "maxTouchPoints": 0 + }, + "screen": { + "width": 2560, + "height": 1600, + "colorDepth": 30, + "availWidth": 2560, + "availHeight": 1480, + "devicePixelRatio": 1 + }, + "webgl": { + "unmaskedVendor": "Apple", + "unmaskedRenderer": "Apple M1, or similar" + }, + "speechVoices": [ + "Albert:en-US:local", + "Alice:it-IT:local", + "Soumya:kn-IN:local", + "Alva:sv-SE:local", + "Aman:en-IN:local", + "Amélie:fr-CA:local", + "Amira:ms-MY:local", + "Anna:de-DE:local", + "Aru:kk-KZ:local", + "Bad News:en-US:local", + "Bahh:en-US:local", + "Bells:en-US:local", + "Boing:en-US:local", + "Bubbles:en-US:local", + "Carmit:he-IL:local", + "Cellos:en-US:local", + "Damayanti:id-ID:local", + "Daniel:en-GB:local", + "Daria:bg-BG:local", + "Wobble:en-US:local", + "Eddy (German (Germany)):de-DE:local", + "Eddy (English (UK)):en-GB:local", + "Eddy (English (US)):en-US:local", + "Eddy (Spanish (Spain)):es-ES:local", + "Eddy (Spanish (Mexico)):es-MX:local", + "Eddy (Finnish (Finland)):fi-FI:local", + "Eddy (French (Canada)):fr-CA:local", + "Eddy (French (France)):fr-FR:local", + "Eddy (Italian (Italy)):it-IT:local", + "Eddy (Japanese (Japan)):ja-JP:local", + "Eddy (Korean (South Korea)):ko-KR:local", + "Eddy (Portuguese (Brazil)):pt-BR:local", + "Eddy (Chinese (China mainland)):zh-CN:local", + "Eddy (Chinese (Taiwan)):zh-TW:local", + "Ellen:nl-BE:local", + "Flo (German (Germany)):de-DE:local", + "Flo (English (UK)):en-GB:local", + "Flo (English (US)):en-US:local", + "Flo (Spanish (Spain)):es-ES:local", + "Flo (Spanish (Mexico)):es-MX:local", + "Flo (Finnish (Finland)):fi-FI:local", + "Flo (French (Canada)):fr-CA:local", + "Flo (French (France)):fr-FR:local", + "Flo (Italian (Italy)):it-IT:local", + "Flo (Japanese (Japan)):ja-JP:local", + "Flo (Korean (South Korea)):ko-KR:local", + "Flo (Portuguese (Brazil)):pt-BR:local", + "Flo (Chinese (China mainland)):zh-CN:local", + "Flo (Chinese (Taiwan)):zh-TW:local", + "Fred:en-US:local", + "Geeta:te-IN:local", + "Good News:en-US:local", + "Grandma (German (Germany)):de-DE:local", + "Grandma (English (UK)):en-GB:local", + "Grandma (English (US)):en-US:local", + "Grandma (Spanish (Spain)):es-ES:local", + "Grandma (Spanish (Mexico)):es-MX:local", + "Grandma (Finnish (Finland)):fi-FI:local", + "Grandma (French (Canada)):fr-CA:local", + "Grandma (French (France)):fr-FR:local", + "Grandma (Italian (Italy)):it-IT:local", + "Grandma (Japanese (Japan)):ja-JP:local", + "Grandma (Korean (South Korea)):ko-KR:local", + "Grandma (Portuguese (Brazil)):pt-BR:local", + "Grandma (Chinese (China mainland)):zh-CN:local", + "Grandma (Chinese (Taiwan)):zh-TW:local", + "Grandpa (German (Germany)):de-DE:local", + "Grandpa (English (UK)):en-GB:local", + "Grandpa (English (US)):en-US:local", + "Grandpa (Spanish (Spain)):es-ES:local", + "Grandpa (Spanish (Mexico)):es-MX:local", + "Grandpa (Finnish (Finland)):fi-FI:local", + "Grandpa (French (Canada)):fr-CA:local", + "Grandpa (French (France)):fr-FR:local", + "Grandpa (Italian (Italy)):it-IT:local", + "Grandpa (Japanese (Japan)):ja-JP:local", + "Grandpa (Korean (South Korea)):ko-KR:local", + "Grandpa (Portuguese (Brazil)):pt-BR:local", + "Grandpa (Chinese (China mainland)):zh-CN:local", + "Grandpa (Chinese (Taiwan)):zh-TW:local", + "Jester:en-US:local", + "Ioana:ro-RO:local", + "Jacques:fr-FR:local", + "Joana:pt-PT:local", + "Junior:en-US:local", + "Kanya:th-TH:local", + "Karen:en-AU:local", + "Kathy:en-US:local", + "Kyoko:ja-JP:local", + "Lana:hr-HR:local", + "Laura:sk-SK:local", + "Lekha:hi-IN:local", + "Lesya:uk-UA:local", + "Linh:vi-VN:local", + "Luciana:pt-BR:local", + "Majed:ar-001:local", + "Tünde:hu-HU:local", + "Meijia:zh-TW:local", + "Melina:el-GR:local", + "Milena:ru-RU:local", + "Moira:en-IE:local", + "Mónica:es-ES:local", + "Montse:ca-ES:local", + "Nora:nb-NO:local", + "Ona:lt-LT:local", + "Organ:en-US:local", + "Paulina:es-MX:local", + "Piya:bn-IN:local", + "Superstar:en-US:local", + "Ralph:en-US:local", + "Reed (German (Germany)):de-DE:local", + "Reed (English (UK)):en-GB:local", + "Reed (English (US)):en-US:local", + "Reed (Spanish (Spain)):es-ES:local", + "Reed (Spanish (Mexico)):es-MX:local", + "Reed (Finnish (Finland)):fi-FI:local", + "Reed (French (Canada)):fr-CA:local", + "Reed (Italian (Italy)):it-IT:local", + "Reed (Japanese (Japan)):ja-JP:local", + "Reed (Korean (South Korea)):ko-KR:local", + "Reed (Portuguese (Brazil)):pt-BR:local", + "Reed (Chinese (China mainland)):zh-CN:local", + "Reed (Chinese (Taiwan)):zh-TW:local", + "Rishi:en-IN:local", + "Rocko (German (Germany)):de-DE:local", + "Rocko (English (UK)):en-GB:local", + "Rocko (English (US)):en-US:local", + "Rocko (Spanish (Spain)):es-ES:local", + "Rocko (Spanish (Mexico)):es-MX:local", + "Rocko (Finnish (Finland)):fi-FI:local", + "Rocko (French (Canada)):fr-CA:local", + "Rocko (French (France)):fr-FR:local", + "Rocko (Italian (Italy)):it-IT:local", + "Rocko (Japanese (Japan)):ja-JP:local", + "Rocko (Korean (South Korea)):ko-KR:local", + "Rocko (Portuguese (Brazil)):pt-BR:local", + "Rocko (Chinese (China mainland)):zh-CN:local", + "Rocko (Chinese (Taiwan)):zh-TW:local", + "Samantha:en-US:local", + "Sandy (German (Germany)):de-DE:local", + "Sandy (English (UK)):en-GB:local", + "Sandy (English (US)):en-US:local", + "Sandy (Spanish (Spain)):es-ES:local", + "Sandy (Spanish (Mexico)):es-MX:local", + "Sandy (Finnish (Finland)):fi-FI:local", + "Sandy (French (Canada)):fr-CA:local", + "Sandy (French (France)):fr-FR:local", + "Sandy (Italian (Italy)):it-IT:local", + "Sandy (Japanese (Japan)):ja-JP:local", + "Sandy (Korean (South Korea)):ko-KR:local", + "Sandy (Portuguese (Brazil)):pt-BR:local", + "Sandy (Chinese (China mainland)):zh-CN:local", + "Sandy (Chinese (Taiwan)):zh-TW:local", + "Sara:da-DK:local", + "Satu:fi-FI:local", + "Shelley (German (Germany)):de-DE:local", + "Shelley (English (UK)):en-GB:local", + "Shelley (English (US)):en-US:local", + "Shelley (Spanish (Spain)):es-ES:local", + "Shelley (Spanish (Mexico)):es-MX:local", + "Shelley (Finnish (Finland)):fi-FI:local", + "Shelley (French (Canada)):fr-CA:local", + "Shelley (French (France)):fr-FR:local", + "Shelley (Italian (Italy)):it-IT:local", + "Shelley (Japanese (Japan)):ja-JP:local", + "Shelley (Korean (South Korea)):ko-KR:local", + "Shelley (Portuguese (Brazil)):pt-BR:local", + "Shelley (Chinese (China mainland)):zh-CN:local", + "Shelley (Chinese (Taiwan)):zh-TW:local", + "Sinji:zh-HK:local", + "Tara:en-IN:local", + "Tessa:en-ZA:local", + "Thomas:fr-FR:local", + "Tina:sl-SI:local", + "Tingting:zh-CN:local", + "Trinoids:en-US:local", + "Vani:ta-IN:local", + "Whisper:en-US:local", + "Xander:nl-NL:local", + "Yelda:tr-TR:local", + "Yuna:ko-KR:local", + "Zarvox:en-US:local", + "Zosia:pl-PL:local", + "Zuzana:cs-CZ:local" + ] + }, + { + "navigator": { + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:149.0) Gecko/20100101 Firefox/149.0", + "platform": "MacIntel", + "hardwareConcurrency": 16, + "maxTouchPoints": 0 + }, + "screen": { + "width": 1728, + "height": 1117, + "colorDepth": 30, + "availWidth": 1728, + "availHeight": 1083, + "devicePixelRatio": 2 + }, + "webgl": { + "unmaskedVendor": "Apple", + "unmaskedRenderer": "Apple M1, or similar" + }, + "speechVoices": [ + "Albert:en-US:local", + "Alice:it-IT:local", + "Soumya:kn-IN:local", + "Alva:sv-SE:local", + "Aman:en-IN:local", + "Amélie:fr-CA:local", + "Amira:ms-MY:local", + "Anna:de-DE:local", + "Aru:kk-KZ:local", + "Bad News:en-US:local", + "Bahh:en-US:local", + "Bells:en-US:local", + "Boing:en-US:local", + "Bubbles:en-US:local", + "Carmit:he-IL:local", + "Cellos:en-US:local", + "Damayanti:id-ID:local", + "Daniel:en-GB:local", + "Daria:bg-BG:local", + "Wobble:en-US:local", + "Eddy (German (Germany)):de-DE:local", + "Eddy (English (UK)):en-GB:local", + "Eddy (English (US)):en-US:local", + "Eddy (Spanish (Spain)):es-ES:local", + "Eddy (Spanish (Mexico)):es-MX:local", + "Eddy (Finnish (Finland)):fi-FI:local", + "Eddy (French (Canada)):fr-CA:local", + "Eddy (French (France)):fr-FR:local", + "Eddy (Italian (Italy)):it-IT:local", + "Eddy (Japanese (Japan)):ja-JP:local", + "Eddy (Korean (South Korea)):ko-KR:local", + "Eddy (Portuguese (Brazil)):pt-BR:local", + "Eddy (Chinese (China mainland)):zh-CN:local", + "Eddy (Chinese (Taiwan)):zh-TW:local", + "Ellen:nl-BE:local", + "Flo (German (Germany)):de-DE:local", + "Flo (English (UK)):en-GB:local", + "Flo (English (US)):en-US:local", + "Flo (Spanish (Spain)):es-ES:local", + "Flo (Spanish (Mexico)):es-MX:local", + "Flo (Finnish (Finland)):fi-FI:local", + "Flo (French (Canada)):fr-CA:local", + "Flo (French (France)):fr-FR:local", + "Flo (Italian (Italy)):it-IT:local", + "Flo (Japanese (Japan)):ja-JP:local", + "Flo (Korean (South Korea)):ko-KR:local", + "Flo (Portuguese (Brazil)):pt-BR:local", + "Flo (Chinese (China mainland)):zh-CN:local", + "Flo (Chinese (Taiwan)):zh-TW:local", + "Fred:en-US:local", + "Geeta:te-IN:local", + "Good News:en-US:local", + "Grandma (German (Germany)):de-DE:local", + "Grandma (English (UK)):en-GB:local", + "Grandma (English (US)):en-US:local", + "Grandma (Spanish (Spain)):es-ES:local", + "Grandma (Spanish (Mexico)):es-MX:local", + "Grandma (Finnish (Finland)):fi-FI:local", + "Grandma (French (Canada)):fr-CA:local", + "Grandma (French (France)):fr-FR:local", + "Grandma (Italian (Italy)):it-IT:local", + "Grandma (Japanese (Japan)):ja-JP:local", + "Grandma (Korean (South Korea)):ko-KR:local", + "Grandma (Portuguese (Brazil)):pt-BR:local", + "Grandma (Chinese (China mainland)):zh-CN:local", + "Grandma (Chinese (Taiwan)):zh-TW:local", + "Grandpa (German (Germany)):de-DE:local", + "Grandpa (English (UK)):en-GB:local", + "Grandpa (English (US)):en-US:local", + "Grandpa (Spanish (Spain)):es-ES:local", + "Grandpa (Spanish (Mexico)):es-MX:local", + "Grandpa (Finnish (Finland)):fi-FI:local", + "Grandpa (French (Canada)):fr-CA:local", + "Grandpa (French (France)):fr-FR:local", + "Grandpa (Italian (Italy)):it-IT:local", + "Grandpa (Japanese (Japan)):ja-JP:local", + "Grandpa (Korean (South Korea)):ko-KR:local", + "Grandpa (Portuguese (Brazil)):pt-BR:local", + "Grandpa (Chinese (China mainland)):zh-CN:local", + "Grandpa (Chinese (Taiwan)):zh-TW:local", + "Jester:en-US:local", + "Ioana:ro-RO:local", + "Jacques:fr-FR:local", + "Joana:pt-PT:local", + "Junior:en-US:local", + "Kanya:th-TH:local", + "Karen:en-AU:local", + "Kathy:en-US:local", + "Kyoko:ja-JP:local", + "Lana:hr-HR:local", + "Laura:sk-SK:local", + "Lekha:hi-IN:local", + "Lesya:uk-UA:local", + "Linh:vi-VN:local", + "Luciana:pt-BR:local", + "Majed:ar-001:local", + "Tünde:hu-HU:local", + "Meijia:zh-TW:local", + "Melina:el-GR:local", + "Milena:ru-RU:local", + "Moira:en-IE:local", + "Mónica:es-ES:local", + "Montse:ca-ES:local", + "Nora:nb-NO:local", + "Ona:lt-LT:local", + "Organ:en-US:local", + "Paulina:es-MX:local", + "Piya:bn-IN:local", + "Superstar:en-US:local", + "Ralph:en-US:local", + "Reed (German (Germany)):de-DE:local", + "Reed (English (UK)):en-GB:local", + "Reed (English (US)):en-US:local", + "Reed (Spanish (Spain)):es-ES:local", + "Reed (Spanish (Mexico)):es-MX:local", + "Reed (Finnish (Finland)):fi-FI:local", + "Reed (French (Canada)):fr-CA:local", + "Reed (Italian (Italy)):it-IT:local", + "Reed (Japanese (Japan)):ja-JP:local", + "Reed (Korean (South Korea)):ko-KR:local", + "Reed (Portuguese (Brazil)):pt-BR:local", + "Reed (Chinese (China mainland)):zh-CN:local", + "Reed (Chinese (Taiwan)):zh-TW:local", + "Rishi:en-IN:local", + "Rocko (German (Germany)):de-DE:local", + "Rocko (English (UK)):en-GB:local", + "Rocko (English (US)):en-US:local", + "Rocko (Spanish (Spain)):es-ES:local", + "Rocko (Spanish (Mexico)):es-MX:local", + "Rocko (Finnish (Finland)):fi-FI:local", + "Rocko (French (Canada)):fr-CA:local", + "Rocko (French (France)):fr-FR:local", + "Rocko (Italian (Italy)):it-IT:local", + "Rocko (Japanese (Japan)):ja-JP:local", + "Rocko (Korean (South Korea)):ko-KR:local", + "Rocko (Portuguese (Brazil)):pt-BR:local", + "Rocko (Chinese (China mainland)):zh-CN:local", + "Rocko (Chinese (Taiwan)):zh-TW:local", + "Samantha:en-US:local", + "Sandy (German (Germany)):de-DE:local", + "Sandy (English (UK)):en-GB:local", + "Sandy (English (US)):en-US:local", + "Sandy (Spanish (Spain)):es-ES:local", + "Sandy (Spanish (Mexico)):es-MX:local", + "Sandy (Finnish (Finland)):fi-FI:local", + "Sandy (French (Canada)):fr-CA:local", + "Sandy (French (France)):fr-FR:local", + "Sandy (Italian (Italy)):it-IT:local", + "Sandy (Japanese (Japan)):ja-JP:local", + "Sandy (Korean (South Korea)):ko-KR:local", + "Sandy (Portuguese (Brazil)):pt-BR:local", + "Sandy (Chinese (China mainland)):zh-CN:local", + "Sandy (Chinese (Taiwan)):zh-TW:local", + "Sara:da-DK:local", + "Satu:fi-FI:local", + "Shelley (German (Germany)):de-DE:local", + "Shelley (English (UK)):en-GB:local", + "Shelley (English (US)):en-US:local", + "Shelley (Spanish (Spain)):es-ES:local", + "Shelley (Spanish (Mexico)):es-MX:local", + "Shelley (Finnish (Finland)):fi-FI:local", + "Shelley (French (Canada)):fr-CA:local", + "Shelley (French (France)):fr-FR:local", + "Shelley (Italian (Italy)):it-IT:local", + "Shelley (Japanese (Japan)):ja-JP:local", + "Shelley (Korean (South Korea)):ko-KR:local", + "Shelley (Portuguese (Brazil)):pt-BR:local", + "Shelley (Chinese (China mainland)):zh-CN:local", + "Shelley (Chinese (Taiwan)):zh-TW:local", + "Sinji:zh-HK:local", + "Tara:en-IN:local", + "Tessa:en-ZA:local", + "Thomas:fr-FR:local", + "Tina:sl-SI:local", + "Tingting:zh-CN:local", + "Trinoids:en-US:local", + "Vani:ta-IN:local", + "Whisper:en-US:local", + "Xander:nl-NL:local", + "Yelda:tr-TR:local", + "Yuna:ko-KR:local", + "Zarvox:en-US:local", + "Zosia:pl-PL:local", + "Zuzana:cs-CZ:local" + ] + }, + { + "navigator": { + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:149.0) Gecko/20100101 Firefox/149.0", + "platform": "MacIntel", + "hardwareConcurrency": 10, + "maxTouchPoints": 0 + }, + "screen": { + "width": 1280, + "height": 832, + "colorDepth": 30, + "availWidth": 1280, + "availHeight": 803, + "devicePixelRatio": 2 + }, + "webgl": { + "unmaskedVendor": "Apple", + "unmaskedRenderer": "Apple M1, or similar" + }, + "speechVoices": [ + "Albert:en-US:local", + "Alice:it-IT:local", + "Soumya:kn-IN:local", + "Alva:sv-SE:local", + "Aman:en-IN:local", + "Amélie:fr-CA:local", + "Amira:ms-MY:local", + "Anna:de-DE:local", + "Aru:kk-KZ:local", + "Malas noticias:en-US:local", + "Bahh:en-US:local", + "Campanas:en-US:local", + "Boing:en-US:local", + "Burbujas:en-US:local", + "Carmit:he-IL:local", + "Violonchelos:en-US:local", + "Damayanti:id-ID:local", + "Daniel:en-GB:local", + "Daria:bg-BG:local", + "Wobble:en-US:local", + "Eddy (Alemán (Alemania)):de-DE:local", + "Eddy (Inglés (RU)):en-GB:local", + "Eddy (Inglés (EE. UU.)):en-US:local", + "Eddy (Español (España)):es-ES:local", + "Eddy (Español (México)):es-MX:local", + "Eddy (Finés (Finlandia)):fi-FI:local", + "Eddy (Francés (Canadá)):fr-CA:local", + "Eddy (Francés (Francia)):fr-FR:local", + "Eddy (Italiano (Italia)):it-IT:local", + "Eddy (Japonés (Japón)):ja-JP:local", + "Eddy (Coreano (Corea del Sur)):ko-KR:local", + "Eddy (Portugués (Brasil)):pt-BR:local", + "Eddy (Chino (China continental)):zh-CN:local", + "Eddy (Chino (Taiwán)):zh-TW:local", + "Ellen:nl-BE:local", + "Flo (Alemán (Alemania)):de-DE:local", + "Flo (Inglés (RU)):en-GB:local", + "Flo (Inglés (EE. UU.)):en-US:local", + "Flo (Español (España)):es-ES:local", + "Flo (Español (México)):es-MX:local", + "Flo (Finés (Finlandia)):fi-FI:local", + "Flo (Francés (Canadá)):fr-CA:local", + "Flo (Francés (Francia)):fr-FR:local", + "Flo (Italiano (Italia)):it-IT:local", + "Flo (Japonés (Japón)):ja-JP:local", + "Flo (Coreano (Corea del Sur)):ko-KR:local", + "Flo (Portugués (Brasil)):pt-BR:local", + "Flo (Chino (China continental)):zh-CN:local", + "Flo (Chino (Taiwán)):zh-TW:local", + "Fred:en-US:local", + "Geeta:te-IN:local", + "Buenas noticias:en-US:local", + "Grandma (Alemán (Alemania)):de-DE:local", + "Grandma (Inglés (RU)):en-GB:local", + "Grandma (Inglés (EE. UU.)):en-US:local", + "Grandma (Español (España)):es-ES:local", + "Grandma (Español (México)):es-MX:local", + "Grandma (Finés (Finlandia)):fi-FI:local", + "Grandma (Francés (Canadá)):fr-CA:local", + "Grandma (Francés (Francia)):fr-FR:local", + "Grandma (Italiano (Italia)):it-IT:local", + "Grandma (Japonés (Japón)):ja-JP:local", + "Grandma (Coreano (Corea del Sur)):ko-KR:local", + "Grandma (Portugués (Brasil)):pt-BR:local", + "Grandma (Chino (China continental)):zh-CN:local", + "Grandma (Chino (Taiwán)):zh-TW:local", + "Grandpa (Alemán (Alemania)):de-DE:local", + "Grandpa (Inglés (RU)):en-GB:local", + "Grandpa (Inglés (EE. UU.)):en-US:local", + "Grandpa (Español (España)):es-ES:local", + "Grandpa (Español (México)):es-MX:local", + "Grandpa (Finés (Finlandia)):fi-FI:local", + "Grandpa (Francés (Canadá)):fr-CA:local", + "Grandpa (Francés (Francia)):fr-FR:local", + "Grandpa (Italiano (Italia)):it-IT:local", + "Grandpa (Japonés (Japón)):ja-JP:local", + "Grandpa (Coreano (Corea del Sur)):ko-KR:local", + "Grandpa (Portugués (Brasil)):pt-BR:local", + "Grandpa (Chino (China continental)):zh-CN:local", + "Grandpa (Chino (Taiwán)):zh-TW:local", + "Bufón:en-US:local", + "Ioana:ro-RO:local", + "Jacques:fr-FR:local", + "Joana:pt-PT:local", + "Junior:en-US:local", + "Kanya:th-TH:local", + "Karen:en-AU:local", + "Kathy:en-US:local", + "Kyoko:ja-JP:local", + "Lana:hr-HR:local", + "Laura:sk-SK:local", + "Lekha:hi-IN:local", + "Lesya:uk-UA:local", + "Linh:vi-VN:local", + "Luciana:pt-BR:local", + "Majed:ar-001:local", + "Tünde:hu-HU:local", + "Meijia:zh-TW:local", + "Melina:el-GR:local", + "Milena:ru-RU:local", + "Moira:en-IE:local", + "Mónica:es-ES:local", + "Montse:ca-ES:local", + "Nora:nb-NO:local", + "Ona:lt-LT:local", + "Órgano:en-US:local", + "Paulina:es-MX:local", + "Piya:bn-IN:local", + "Superestrella:en-US:local", + "Ralph:en-US:local", + "Reed (Alemán (Alemania)):de-DE:local", + "Reed (Inglés (RU)):en-GB:local", + "Reed (Inglés (EE. UU.)):en-US:local", + "Reed (Español (España)):es-ES:local", + "Reed (Español (México)):es-MX:local", + "Reed (Finés (Finlandia)):fi-FI:local", + "Reed (Francés (Canadá)):fr-CA:local", + "Reed (Italiano (Italia)):it-IT:local", + "Reed (Japonés (Japón)):ja-JP:local", + "Reed (Coreano (Corea del Sur)):ko-KR:local", + "Reed (Portugués (Brasil)):pt-BR:local", + "Reed (Chino (China continental)):zh-CN:local", + "Reed (Chino (Taiwán)):zh-TW:local", + "Rishi:en-IN:local", + "Rocko (Alemán (Alemania)):de-DE:local", + "Rocko (Inglés (RU)):en-GB:local", + "Rocko (Inglés (EE. UU.)):en-US:local", + "Rocko (Español (España)):es-ES:local", + "Rocko (Español (México)):es-MX:local", + "Rocko (Finés (Finlandia)):fi-FI:local", + "Rocko (Francés (Canadá)):fr-CA:local", + "Rocko (Francés (Francia)):fr-FR:local", + "Rocko (Italiano (Italia)):it-IT:local", + "Rocko (Japonés (Japón)):ja-JP:local", + "Rocko (Coreano (Corea del Sur)):ko-KR:local", + "Rocko (Portugués (Brasil)):pt-BR:local", + "Rocko (Chino (China continental)):zh-CN:local", + "Rocko (Chino (Taiwán)):zh-TW:local", + "Samantha:en-US:local", + "Sandy (Alemán (Alemania)):de-DE:local", + "Sandy (Inglés (RU)):en-GB:local", + "Sandy (Inglés (EE. UU.)):en-US:local", + "Sandy (Español (España)):es-ES:local", + "Sandy (Español (México)):es-MX:local", + "Sandy (Finés (Finlandia)):fi-FI:local", + "Sandy (Francés (Canadá)):fr-CA:local", + "Sandy (Francés (Francia)):fr-FR:local", + "Sandy (Italiano (Italia)):it-IT:local", + "Sandy (Japonés (Japón)):ja-JP:local", + "Sandy (Coreano (Corea del Sur)):ko-KR:local", + "Sandy (Portugués (Brasil)):pt-BR:local", + "Sandy (Chino (China continental)):zh-CN:local", + "Sandy (Chino (Taiwán)):zh-TW:local", + "Sara:da-DK:local", + "Satu:fi-FI:local", + "Shelley (Alemán (Alemania)):de-DE:local", + "Shelley (Inglés (RU)):en-GB:local", + "Shelley (Inglés (EE. UU.)):en-US:local", + "Shelley (Español (España)):es-ES:local", + "Shelley (Español (México)):es-MX:local", + "Shelley (Finés (Finlandia)):fi-FI:local", + "Shelley (Francés (Canadá)):fr-CA:local", + "Shelley (Francés (Francia)):fr-FR:local", + "Shelley (Italiano (Italia)):it-IT:local", + "Shelley (Japonés (Japón)):ja-JP:local", + "Shelley (Coreano (Corea del Sur)):ko-KR:local", + "Shelley (Portugués (Brasil)):pt-BR:local", + "Shelley (Chino (China continental)):zh-CN:local", + "Shelley (Chino (Taiwán)):zh-TW:local", + "Sinji:zh-HK:local", + "Tara:en-IN:local", + "Tessa:en-ZA:local", + "Thomas:fr-FR:local", + "Tina:sl-SI:local", + "Tingting:zh-CN:local", + "Trinoids:en-US:local", + "Vani:ta-IN:local", + "Susurro:en-US:local", + "Xander:nl-NL:local", + "Yelda:tr-TR:local", + "Yuna:ko-KR:local", + "Zarvox:en-US:local", + "Zosia:pl-PL:local", + "Zuzana:cs-CZ:local" + ] + }, + { + "navigator": { + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:150.0) Gecko/20100101 Firefox/150.0", + "platform": "MacIntel", + "hardwareConcurrency": 8, + "maxTouchPoints": 0 + }, + "screen": { + "width": 1680, + "height": 1050, + "colorDepth": 30, + "availWidth": 1680, + "availHeight": 934, + "devicePixelRatio": 2 + }, + "webgl": { + "unmaskedVendor": "Apple", + "unmaskedRenderer": "Apple M1, or similar" + }, + "speechVoices": [ + "Albert:en-US:local", + "Alice:it-IT:local", + "Soumya:kn-IN:local", + "Alva:sv-SE:local", + "Aman:en-IN:local", + "Amélie:fr-CA:local", + "Amira:ms-MY:local", + "Anna:de-DE:local", + "Aru:kk-KZ:local", + "Bad News:en-US:local", + "Bahh:en-US:local", + "Bells:en-US:local", + "Boing:en-US:local", + "Bubbles:en-US:local", + "Carmit:he-IL:local", + "Cellos:en-US:local", + "Damayanti:id-ID:local", + "Daniel:en-GB:local", + "Daria:bg-BG:local", + "Wobble:en-US:local", + "Eddy (German (Germany)):de-DE:local", + "Eddy (English (UK)):en-GB:local", + "Eddy (English (US)):en-US:local", + "Eddy (Spanish (Spain)):es-ES:local", + "Eddy (Spanish (Mexico)):es-MX:local", + "Eddy (Finnish (Finland)):fi-FI:local", + "Eddy (French (Canada)):fr-CA:local", + "Eddy (French (France)):fr-FR:local", + "Eddy (Italian (Italy)):it-IT:local", + "Eddy (Japanese (Japan)):ja-JP:local", + "Eddy (Korean (South Korea)):ko-KR:local", + "Eddy (Portuguese (Brazil)):pt-BR:local", + "Eddy (Chinese (China mainland)):zh-CN:local", + "Eddy (Chinese (Taiwan)):zh-TW:local", + "Ellen:nl-BE:local", + "Flo (German (Germany)):de-DE:local", + "Flo (English (UK)):en-GB:local", + "Flo (English (US)):en-US:local", + "Flo (Spanish (Spain)):es-ES:local", + "Flo (Spanish (Mexico)):es-MX:local", + "Flo (Finnish (Finland)):fi-FI:local", + "Flo (French (Canada)):fr-CA:local", + "Flo (French (France)):fr-FR:local", + "Flo (Italian (Italy)):it-IT:local", + "Flo (Japanese (Japan)):ja-JP:local", + "Flo (Korean (South Korea)):ko-KR:local", + "Flo (Portuguese (Brazil)):pt-BR:local", + "Flo (Chinese (China mainland)):zh-CN:local", + "Flo (Chinese (Taiwan)):zh-TW:local", + "Fred:en-US:local", + "Geeta:te-IN:local", + "Good News:en-US:local", + "Grandma (German (Germany)):de-DE:local", + "Grandma (English (UK)):en-GB:local", + "Grandma (English (US)):en-US:local", + "Grandma (Spanish (Spain)):es-ES:local", + "Grandma (Spanish (Mexico)):es-MX:local", + "Grandma (Finnish (Finland)):fi-FI:local", + "Grandma (French (Canada)):fr-CA:local", + "Grandma (French (France)):fr-FR:local", + "Grandma (Italian (Italy)):it-IT:local", + "Grandma (Japanese (Japan)):ja-JP:local", + "Grandma (Korean (South Korea)):ko-KR:local", + "Grandma (Portuguese (Brazil)):pt-BR:local", + "Grandma (Chinese (China mainland)):zh-CN:local", + "Grandma (Chinese (Taiwan)):zh-TW:local", + "Grandpa (German (Germany)):de-DE:local", + "Grandpa (English (UK)):en-GB:local", + "Grandpa (English (US)):en-US:local", + "Grandpa (Spanish (Spain)):es-ES:local", + "Grandpa (Spanish (Mexico)):es-MX:local", + "Grandpa (Finnish (Finland)):fi-FI:local", + "Grandpa (French (Canada)):fr-CA:local", + "Grandpa (French (France)):fr-FR:local", + "Grandpa (Italian (Italy)):it-IT:local", + "Grandpa (Japanese (Japan)):ja-JP:local", + "Grandpa (Korean (South Korea)):ko-KR:local", + "Grandpa (Portuguese (Brazil)):pt-BR:local", + "Grandpa (Chinese (China mainland)):zh-CN:local", + "Grandpa (Chinese (Taiwan)):zh-TW:local", + "Jester:en-US:local", + "Ioana:ro-RO:local", + "Jacques:fr-FR:local", + "Joana:pt-PT:local", + "Junior:en-US:local", + "Kanya:th-TH:local", + "Karen:en-AU:local", + "Kathy:en-US:local", + "Kyoko:ja-JP:local", + "Lana:hr-HR:local", + "Laura:sk-SK:local", + "Lekha:hi-IN:local", + "Lesya:uk-UA:local", + "Linh:vi-VN:local", + "Luciana:pt-BR:local", + "Majed:ar-001:local", + "Tünde:hu-HU:local", + "Meijia:zh-TW:local", + "Melina:el-GR:local", + "Milena:ru-RU:local", + "Moira:en-IE:local", + "Mónica:es-ES:local", + "Montse:ca-ES:local", + "Nora:nb-NO:local", + "Ona:lt-LT:local", + "Organ:en-US:local", + "Paulina:es-MX:local", + "Piya:bn-IN:local", + "Superstar:en-US:local", + "Ralph:en-US:local", + "Reed (German (Germany)):de-DE:local", + "Reed (English (UK)):en-GB:local", + "Reed (English (US)):en-US:local", + "Reed (Spanish (Spain)):es-ES:local", + "Reed (Spanish (Mexico)):es-MX:local", + "Reed (Finnish (Finland)):fi-FI:local", + "Reed (French (Canada)):fr-CA:local", + "Reed (Italian (Italy)):it-IT:local", + "Reed (Japanese (Japan)):ja-JP:local", + "Reed (Korean (South Korea)):ko-KR:local", + "Reed (Portuguese (Brazil)):pt-BR:local", + "Reed (Chinese (China mainland)):zh-CN:local", + "Reed (Chinese (Taiwan)):zh-TW:local", + "Rishi:en-IN:local", + "Rocko (German (Germany)):de-DE:local", + "Rocko (English (UK)):en-GB:local", + "Rocko (English (US)):en-US:local", + "Rocko (Spanish (Spain)):es-ES:local", + "Rocko (Spanish (Mexico)):es-MX:local", + "Rocko (Finnish (Finland)):fi-FI:local", + "Rocko (French (Canada)):fr-CA:local", + "Rocko (French (France)):fr-FR:local", + "Rocko (Italian (Italy)):it-IT:local", + "Rocko (Japanese (Japan)):ja-JP:local", + "Rocko (Korean (South Korea)):ko-KR:local", + "Rocko (Portuguese (Brazil)):pt-BR:local", + "Rocko (Chinese (China mainland)):zh-CN:local", + "Rocko (Chinese (Taiwan)):zh-TW:local", + "Samantha:en-US:local", + "Sandy (German (Germany)):de-DE:local", + "Sandy (English (UK)):en-GB:local", + "Sandy (English (US)):en-US:local", + "Sandy (Spanish (Spain)):es-ES:local", + "Sandy (Spanish (Mexico)):es-MX:local", + "Sandy (Finnish (Finland)):fi-FI:local", + "Sandy (French (Canada)):fr-CA:local", + "Sandy (French (France)):fr-FR:local", + "Sandy (Italian (Italy)):it-IT:local", + "Sandy (Japanese (Japan)):ja-JP:local", + "Sandy (Korean (South Korea)):ko-KR:local", + "Sandy (Portuguese (Brazil)):pt-BR:local", + "Sandy (Chinese (China mainland)):zh-CN:local", + "Sandy (Chinese (Taiwan)):zh-TW:local", + "Sara:da-DK:local", + "Satu:fi-FI:local", + "Shelley (German (Germany)):de-DE:local", + "Shelley (English (UK)):en-GB:local", + "Shelley (English (US)):en-US:local", + "Shelley (Spanish (Spain)):es-ES:local", + "Shelley (Spanish (Mexico)):es-MX:local", + "Shelley (Finnish (Finland)):fi-FI:local", + "Shelley (French (Canada)):fr-CA:local", + "Shelley (French (France)):fr-FR:local", + "Shelley (Italian (Italy)):it-IT:local", + "Shelley (Japanese (Japan)):ja-JP:local", + "Shelley (Korean (South Korea)):ko-KR:local", + "Shelley (Portuguese (Brazil)):pt-BR:local", + "Shelley (Chinese (China mainland)):zh-CN:local", + "Shelley (Chinese (Taiwan)):zh-TW:local", + "Sinji:zh-HK:local", + "Tara:en-IN:local", + "Tessa:en-ZA:local", + "Thomas:fr-FR:local", + "Tina:sl-SI:local", + "Tingting:zh-CN:local", + "Trinoids:en-US:local", + "Vani:ta-IN:local", + "Whisper:en-US:local", + "Xander:nl-NL:local", + "Yelda:tr-TR:local", + "Yuna:ko-KR:local", + "Zarvox:en-US:local", + "Zosia:pl-PL:local", + "Zuzana:cs-CZ:local" + ] + }, + { + "navigator": { + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:152.0) Gecko/20100101 Firefox/152.0", + "platform": "MacIntel", + "hardwareConcurrency": 10, + "maxTouchPoints": 0 + }, + "screen": { + "width": 1920, + "height": 1080, + "colorDepth": 30, + "availWidth": 1920, + "availHeight": 1004, + "devicePixelRatio": 2 + }, + "webgl": { + "unmaskedVendor": "Apple", + "unmaskedRenderer": "Apple M1, or similar" + }, + "speechVoices": [ + "Albert:en-US:local", + "Alice:it-IT:local", + "Soumya:kn-IN:local", + "Alva:sv-SE:local", + "Aman:en-IN:local", + "Amélie:fr-CA:local", + "Amira:ms-MY:local", + "Anna:de-DE:local", + "Aru:kk-KZ:local", + "Bad News:en-US:local", + "Bahh:en-US:local", + "Bells:en-US:local", + "Boing:en-US:local", + "Bubbles:en-US:local", + "Carmit:he-IL:local", + "Cellos:en-US:local", + "Damayanti:id-ID:local", + "Daniel:en-GB:local", + "Daria:bg-BG:local", + "Wobble:en-US:local", + "Eddy (German (Germany)):de-DE:local", + "Eddy (English (UK)):en-GB:local", + "Eddy (English (US)):en-US:local", + "Eddy (Spanish (Spain)):es-ES:local", + "Eddy (Spanish (Mexico)):es-MX:local", + "Eddy (Finnish (Finland)):fi-FI:local", + "Eddy (French (Canada)):fr-CA:local", + "Eddy (French (France)):fr-FR:local", + "Eddy (Italian (Italy)):it-IT:local", + "Eddy (Japanese (Japan)):ja-JP:local", + "Eddy (Korean (South Korea)):ko-KR:local", + "Eddy (Portuguese (Brazil)):pt-BR:local", + "Eddy (Chinese (China mainland)):zh-CN:local", + "Eddy (Chinese (Taiwan)):zh-TW:local", + "Ellen:nl-BE:local", + "Flo (German (Germany)):de-DE:local", + "Flo (English (UK)):en-GB:local", + "Flo (English (US)):en-US:local", + "Flo (Spanish (Spain)):es-ES:local", + "Flo (Spanish (Mexico)):es-MX:local", + "Flo (Finnish (Finland)):fi-FI:local", + "Flo (French (Canada)):fr-CA:local", + "Flo (French (France)):fr-FR:local", + "Flo (Italian (Italy)):it-IT:local", + "Flo (Japanese (Japan)):ja-JP:local", + "Flo (Korean (South Korea)):ko-KR:local", + "Flo (Portuguese (Brazil)):pt-BR:local", + "Flo (Chinese (China mainland)):zh-CN:local", + "Flo (Chinese (Taiwan)):zh-TW:local", + "Fred:en-US:local", + "Geeta:te-IN:local", + "Good News:en-US:local", + "Grandma (German (Germany)):de-DE:local", + "Grandma (English (UK)):en-GB:local", + "Grandma (English (US)):en-US:local", + "Grandma (Spanish (Spain)):es-ES:local", + "Grandma (Spanish (Mexico)):es-MX:local", + "Grandma (Finnish (Finland)):fi-FI:local", + "Grandma (French (Canada)):fr-CA:local", + "Grandma (French (France)):fr-FR:local", + "Grandma (Italian (Italy)):it-IT:local", + "Grandma (Japanese (Japan)):ja-JP:local", + "Grandma (Korean (South Korea)):ko-KR:local", + "Grandma (Portuguese (Brazil)):pt-BR:local", + "Grandma (Chinese (China mainland)):zh-CN:local", + "Grandma (Chinese (Taiwan)):zh-TW:local", + "Grandpa (German (Germany)):de-DE:local", + "Grandpa (English (UK)):en-GB:local", + "Grandpa (English (US)):en-US:local", + "Grandpa (Spanish (Spain)):es-ES:local", + "Grandpa (Spanish (Mexico)):es-MX:local", + "Grandpa (Finnish (Finland)):fi-FI:local", + "Grandpa (French (Canada)):fr-CA:local", + "Grandpa (French (France)):fr-FR:local", + "Grandpa (Italian (Italy)):it-IT:local", + "Grandpa (Japanese (Japan)):ja-JP:local", + "Grandpa (Korean (South Korea)):ko-KR:local", + "Grandpa (Portuguese (Brazil)):pt-BR:local", + "Grandpa (Chinese (China mainland)):zh-CN:local", + "Grandpa (Chinese (Taiwan)):zh-TW:local", + "Jester:en-US:local", + "Ioana:ro-RO:local", + "Jacques:fr-FR:local", + "Joana:pt-PT:local", + "Junior:en-US:local", + "Kanya:th-TH:local", + "Karen:en-AU:local", + "Kathy:en-US:local", + "Kyoko:ja-JP:local", + "Lana:hr-HR:local", + "Laura:sk-SK:local", + "Lekha:hi-IN:local", + "Lesya:uk-UA:local", + "Linh:vi-VN:local", + "Luciana:pt-BR:local", + "Majed:ar-001:local", + "Tünde:hu-HU:local", + "Meijia:zh-TW:local", + "Melina:el-GR:local", + "Milena:ru-RU:local", + "Moira:en-IE:local", + "Mónica:es-ES:local", + "Montse:ca-ES:local", + "Nora:nb-NO:local", + "Ona:lt-LT:local", + "Organ:en-US:local", + "Paulina:es-MX:local", + "Piya:bn-IN:local", + "Superstar:en-US:local", + "Ralph:en-US:local", + "Reed (German (Germany)):de-DE:local", + "Reed (English (UK)):en-GB:local", + "Reed (English (US)):en-US:local", + "Reed (Spanish (Spain)):es-ES:local", + "Reed (Spanish (Mexico)):es-MX:local", + "Reed (Finnish (Finland)):fi-FI:local", + "Reed (French (Canada)):fr-CA:local", + "Reed (Italian (Italy)):it-IT:local", + "Reed (Japanese (Japan)):ja-JP:local", + "Reed (Korean (South Korea)):ko-KR:local", + "Reed (Portuguese (Brazil)):pt-BR:local", + "Reed (Chinese (China mainland)):zh-CN:local", + "Reed (Chinese (Taiwan)):zh-TW:local", + "Rishi:en-IN:local", + "Rocko (German (Germany)):de-DE:local", + "Rocko (English (UK)):en-GB:local", + "Rocko (English (US)):en-US:local", + "Rocko (Spanish (Spain)):es-ES:local", + "Rocko (Spanish (Mexico)):es-MX:local", + "Rocko (Finnish (Finland)):fi-FI:local", + "Rocko (French (Canada)):fr-CA:local", + "Rocko (French (France)):fr-FR:local", + "Rocko (Italian (Italy)):it-IT:local", + "Rocko (Japanese (Japan)):ja-JP:local", + "Rocko (Korean (South Korea)):ko-KR:local", + "Rocko (Portuguese (Brazil)):pt-BR:local", + "Rocko (Chinese (China mainland)):zh-CN:local", + "Rocko (Chinese (Taiwan)):zh-TW:local", + "Samantha:en-US:local", + "Sandy (German (Germany)):de-DE:local", + "Sandy (English (UK)):en-GB:local", + "Sandy (English (US)):en-US:local", + "Sandy (Spanish (Spain)):es-ES:local", + "Sandy (Spanish (Mexico)):es-MX:local", + "Sandy (Finnish (Finland)):fi-FI:local", + "Sandy (French (Canada)):fr-CA:local", + "Sandy (French (France)):fr-FR:local", + "Sandy (Italian (Italy)):it-IT:local", + "Sandy (Japanese (Japan)):ja-JP:local", + "Sandy (Korean (South Korea)):ko-KR:local", + "Sandy (Portuguese (Brazil)):pt-BR:local", + "Sandy (Chinese (China mainland)):zh-CN:local", + "Sandy (Chinese (Taiwan)):zh-TW:local", + "Sara:da-DK:local", + "Satu:fi-FI:local", + "Shelley (German (Germany)):de-DE:local", + "Shelley (English (UK)):en-GB:local", + "Shelley (English (US)):en-US:local", + "Shelley (Spanish (Spain)):es-ES:local", + "Shelley (Spanish (Mexico)):es-MX:local", + "Shelley (Finnish (Finland)):fi-FI:local", + "Shelley (French (Canada)):fr-CA:local", + "Shelley (French (France)):fr-FR:local", + "Shelley (Italian (Italy)):it-IT:local", + "Shelley (Japanese (Japan)):ja-JP:local", + "Shelley (Korean (South Korea)):ko-KR:local", + "Shelley (Portuguese (Brazil)):pt-BR:local", + "Shelley (Chinese (China mainland)):zh-CN:local", + "Shelley (Chinese (Taiwan)):zh-TW:local", + "Sinji:zh-HK:local", + "Tara:en-IN:local", + "Tessa:en-ZA:local", + "Thomas:fr-FR:local", + "Tina:sl-SI:local", + "Tingting:zh-CN:local", + "Trinoids:en-US:local", + "Vani:ta-IN:local", + "Whisper:en-US:local", + "Xander:nl-NL:local", + "Yelda:tr-TR:local", + "Yuna:ko-KR:local", + "Zarvox:en-US:local", + "Zosia:pl-PL:local", + "Zuzana:cs-CZ:local" + ] + }, + { + "navigator": { + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:149.0) Gecko/20100101 Firefox/149.0", + "platform": "MacIntel", + "hardwareConcurrency": 2, + "maxTouchPoints": 0 + }, + "screen": { + "width": 1280, + "height": 800, + "colorDepth": 30, + "availWidth": 1280, + "availHeight": 719, + "devicePixelRatio": 2 + }, + "webgl": { + "unmaskedVendor": "Intel Inc.", + "unmaskedRenderer": "Intel(R) HD Graphics 400, or similar" + }, + "speechVoices": [ + "Alex:en-US:local", + "Alice:it-IT:local", + "Alva:sv-SE:local", + "Amelie:fr-CA:local", + "Anna:de-DE:local", + "Carmit:he-IL:local", + "Damayanti:id-ID:local", + "Daniel:en-GB:local", + "Diego:es-AR:local", + "Ellen:nl-BE:local", + "Fiona:en-scotland:local", + "Fred:en-US:local", + "Ioana:ro-RO:local", + "Joana:pt-PT:local", + "Jorge:es-ES:local", + "Juan:es-MX:local", + "Kanya:th-TH:local", + "Karen:en-AU:local", + "Kyoko:ja-JP:local", + "Laura:sk-SK:local", + "Lekha:hi-IN:local", + "Luca:it-IT:local", + "Luciana:pt-BR:local", + "Maged:ar-SA:local", + "Mariska:hu-HU:local", + "Mei-Jia:zh-TW:local", + "Melina:el-GR:local", + "Milena:ru-RU:local", + "Moira:en-IE:local", + "Monica:es-ES:local", + "Nora:nb-NO:local", + "Paulina:es-MX:local", + "Rishi:en-IN:local", + "Samantha:en-US:local", + "Sara:da-DK:local", + "Satu:fi-FI:local", + "Sin-ji:zh-HK:local", + "Tessa:en-ZA:local", + "Thomas:fr-FR:local", + "Ting-Ting:zh-CN:local", + "Veena:en-IN:local", + "Victoria:en-US:local", + "Xander:nl-NL:local", + "Yelda:tr-TR:local", + "Yuna:ko-KR:local", + "Yuri:ru-RU:local", + "Zosia:pl-PL:local", + "Zuzana:cs-CZ:local" + ] + }, + { + "navigator": { + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:150.0) Gecko/20100101 Firefox/150.0", + "platform": "MacIntel", + "hardwareConcurrency": 6, + "maxTouchPoints": 0 + }, + "screen": { + "width": 1920, + "height": 1080, + "colorDepth": 30, + "availWidth": 1920, + "availHeight": 984, + "devicePixelRatio": 1 + }, + "webgl": { + "unmaskedVendor": "ATI Technologies Inc.", + "unmaskedRenderer": "Radeon HD 3200 Graphics, or similar" + }, + "speechVoices": [ + "Albert:en-US:local", + "Alice:it-IT:local", + "Alva:sv-SE:local", + "Amélie:fr-CA:local", + "Amira:ms-MY:local", + "Anna:de-DE:local", + "Bad News:en-US:local", + "Bahh:en-US:local", + "Bells:en-US:local", + "Boing:en-US:local", + "Bubbles:en-US:local", + "Carmit:he-IL:local", + "Cellos:en-US:local", + "Damayanti:id-ID:local", + "Daniel:en-GB:local", + "Daria:bg-BG:local", + "Wobble:en-US:local", + "Eddy (German (Germany)):de-DE:local", + "Eddy (English (UK)):en-GB:local", + "Eddy (English (US)):en-US:local", + "Eddy (Spanish (Spain)):es-ES:local", + "Eddy (Spanish (Mexico)):es-MX:local", + "Eddy (Finnish (Finland)):fi-FI:local", + "Eddy (French (Canada)):fr-CA:local", + "Eddy (French (France)):fr-FR:local", + "Eddy (Italian (Italy)):it-IT:local", + "Eddy (Portuguese (Brazil)):pt-BR:local", + "Ellen:nl-BE:local", + "Flo (German (Germany)):de-DE:local", + "Flo (English (UK)):en-GB:local", + "Flo (English (US)):en-US:local", + "Flo (Spanish (Spain)):es-ES:local", + "Flo (Spanish (Mexico)):es-MX:local", + "Flo (Finnish (Finland)):fi-FI:local", + "Flo (French (Canada)):fr-CA:local", + "Flo (French (France)):fr-FR:local", + "Flo (Italian (Italy)):it-IT:local", + "Flo (Portuguese (Brazil)):pt-BR:local", + "Fred:en-US:local", + "Good News:en-US:local", + "Grandma (German (Germany)):de-DE:local", + "Grandma (English (UK)):en-GB:local", + "Grandma (English (US)):en-US:local", + "Grandma (Spanish (Spain)):es-ES:local", + "Grandma (Spanish (Mexico)):es-MX:local", + "Grandma (Finnish (Finland)):fi-FI:local", + "Grandma (French (Canada)):fr-CA:local", + "Grandma (French (France)):fr-FR:local", + "Grandma (Italian (Italy)):it-IT:local", + "Grandma (Portuguese (Brazil)):pt-BR:local", + "Grandpa (German (Germany)):de-DE:local", + "Grandpa (English (UK)):en-GB:local", + "Grandpa (English (US)):en-US:local", + "Grandpa (Spanish (Spain)):es-ES:local", + "Grandpa (Spanish (Mexico)):es-MX:local", + "Grandpa (Finnish (Finland)):fi-FI:local", + "Grandpa (French (Canada)):fr-CA:local", + "Grandpa (French (France)):fr-FR:local", + "Grandpa (Italian (Italy)):it-IT:local", + "Grandpa (Portuguese (Brazil)):pt-BR:local", + "Jester:en-US:local", + "Ioana:ro-RO:local", + "Jacques:fr-FR:local", + "Joana:pt-PT:local", + "Junior:en-US:local", + "Kanya:th-TH:local", + "Karen:en-AU:local", + "Kathy:en-US:local", + "Kyoko:ja-JP:local", + "Lana:hr-HR:local", + "Laura:sk-SK:local", + "Lekha:hi-IN:local", + "Lesya:uk-UA:local", + "Linh:vi-VN:local", + "Luciana:pt-BR:local", + "Majed:ar-001:local", + "Tünde:hu-HU:local", + "Meijia:zh-TW:local", + "Melina:el-GR:local", + "Milena:ru-RU:local", + "Moira:en-IE:local", + "Mónica:es-ES:local", + "Montse:ca-ES:local", + "Nora:nb-NO:local", + "Organ:en-US:local", + "Paulina:es-MX:local", + "Superstar:en-US:local", + "Ralph:en-US:local", + "Reed (German (Germany)):de-DE:local", + "Reed (English (UK)):en-GB:local", + "Reed (English (US)):en-US:local", + "Reed (Spanish (Spain)):es-ES:local", + "Reed (Spanish (Mexico)):es-MX:local", + "Reed (Finnish (Finland)):fi-FI:local", + "Reed (French (Canada)):fr-CA:local", + "Reed (Italian (Italy)):it-IT:local", + "Reed (Portuguese (Brazil)):pt-BR:local", + "Rishi:en-IN:local", + "Rocko (German (Germany)):de-DE:local", + "Rocko (English (UK)):en-GB:local", + "Rocko (English (US)):en-US:local", + "Rocko (Spanish (Spain)):es-ES:local", + "Rocko (Spanish (Mexico)):es-MX:local", + "Rocko (Finnish (Finland)):fi-FI:local", + "Rocko (French (Canada)):fr-CA:local", + "Rocko (French (France)):fr-FR:local", + "Rocko (Italian (Italy)):it-IT:local", + "Rocko (Portuguese (Brazil)):pt-BR:local", + "Samantha:en-US:local", + "Sandy (German (Germany)):de-DE:local", + "Sandy (English (UK)):en-GB:local", + "Sandy (English (US)):en-US:local", + "Sandy (Spanish (Spain)):es-ES:local", + "Sandy (Spanish (Mexico)):es-MX:local", + "Sandy (Finnish (Finland)):fi-FI:local", + "Sandy (French (Canada)):fr-CA:local", + "Sandy (French (France)):fr-FR:local", + "Sandy (Italian (Italy)):it-IT:local", + "Sandy (Portuguese (Brazil)):pt-BR:local", + "Sara:da-DK:local", + "Satu:fi-FI:local", + "Shelley (German (Germany)):de-DE:local", + "Shelley (English (UK)):en-GB:local", + "Shelley (English (US)):en-US:local", + "Shelley (Spanish (Spain)):es-ES:local", + "Shelley (Spanish (Mexico)):es-MX:local", + "Shelley (Finnish (Finland)):fi-FI:local", + "Shelley (French (Canada)):fr-CA:local", + "Shelley (French (France)):fr-FR:local", + "Shelley (Italian (Italy)):it-IT:local", + "Shelley (Portuguese (Brazil)):pt-BR:local", + "Sinji:zh-HK:local", + "Tessa:en-ZA:local", + "Thomas:fr-FR:local", + "Tingting:zh-CN:local", + "Trinoids:en-US:local", + "Whisper:en-US:local", + "Xander:nl-NL:local", + "Yelda:tr-TR:local", + "Yuna:ko-KR:local", + "Zarvox:en-US:local", + "Zosia:pl-PL:local", + "Zuzana:cs-CZ:local" + ] + }, + { + "navigator": { + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:150.0) Gecko/20100101 Firefox/150.0", + "platform": "MacIntel", + "hardwareConcurrency": 8, + "maxTouchPoints": 0 + }, + "screen": { + "width": 1710, + "height": 1112, + "colorDepth": 30, + "availWidth": 1710, + "availHeight": 1073, + "devicePixelRatio": 2 + }, + "webgl": { + "unmaskedVendor": "Apple", + "unmaskedRenderer": "Apple M1, or similar" + }, + "speechVoices": [ + "Albert:en-US:local", + "Alice:it-IT:local", + "Soumya:kn-IN:local", + "Alva:sv-SE:local", + "Aman (English (India)):en-IN:local", + "Aman (English (India)):en-IN:local", + "Amélie:fr-CA:local", + "Amira:ms-MY:local", + "Anna:de-DE:local", + "Aru:kk-KZ:local", + "Bad News:en-US:local", + "Bahh:en-US:local", + "Bells:en-US:local", + "Boing:en-US:local", + "Bubbles:en-US:local", + "Carmit:he-IL:local", + "Cellos:en-US:local", + "Damayanti:id-ID:local", + "Daniel:en-GB:local", + "Daria:bg-BG:local", + "Wobble:en-US:local", + "Eddy (German (Germany)):de-DE:local", + "Eddy (English (UK)):en-GB:local", + "Eddy (English (US)):en-US:local", + "Eddy (Spanish (Spain)):es-ES:local", + "Eddy (Spanish (Mexico)):es-MX:local", + "Eddy (Finnish (Finland)):fi-FI:local", + "Eddy (French (Canada)):fr-CA:local", + "Eddy (French (France)):fr-FR:local", + "Eddy (Italian (Italy)):it-IT:local", + "Eddy (Japanese (Japan)):ja-JP:local", + "Eddy (Korean (South Korea)):ko-KR:local", + "Eddy (Portuguese (Brazil)):pt-BR:local", + "Eddy (Chinese (China mainland)):zh-CN:local", + "Eddy (Chinese (Taiwan)):zh-TW:local", + "Ellen:nl-BE:local", + "Flo (German (Germany)):de-DE:local", + "Flo (English (UK)):en-GB:local", + "Flo (English (US)):en-US:local", + "Flo (Spanish (Spain)):es-ES:local", + "Flo (Spanish (Mexico)):es-MX:local", + "Flo (Finnish (Finland)):fi-FI:local", + "Flo (French (Canada)):fr-CA:local", + "Flo (French (France)):fr-FR:local", + "Flo (Italian (Italy)):it-IT:local", + "Flo (Japanese (Japan)):ja-JP:local", + "Flo (Korean (South Korea)):ko-KR:local", + "Flo (Portuguese (Brazil)):pt-BR:local", + "Flo (Chinese (China mainland)):zh-CN:local", + "Flo (Chinese (Taiwan)):zh-TW:local", + "Fred:en-US:local", + "Geeta:te-IN:local", + "Good News:en-US:local", + "Grandma (German (Germany)):de-DE:local", + "Grandma (English (UK)):en-GB:local", + "Grandma (English (US)):en-US:local", + "Grandma (Spanish (Spain)):es-ES:local", + "Grandma (Spanish (Mexico)):es-MX:local", + "Grandma (Finnish (Finland)):fi-FI:local", + "Grandma (French (Canada)):fr-CA:local", + "Grandma (French (France)):fr-FR:local", + "Grandma (Italian (Italy)):it-IT:local", + "Grandma (Japanese (Japan)):ja-JP:local", + "Grandma (Korean (South Korea)):ko-KR:local", + "Grandma (Portuguese (Brazil)):pt-BR:local", + "Grandma (Chinese (China mainland)):zh-CN:local", + "Grandma (Chinese (Taiwan)):zh-TW:local", + "Grandpa (German (Germany)):de-DE:local", + "Grandpa (English (UK)):en-GB:local", + "Grandpa (English (US)):en-US:local", + "Grandpa (Spanish (Spain)):es-ES:local", + "Grandpa (Spanish (Mexico)):es-MX:local", + "Grandpa (Finnish (Finland)):fi-FI:local", + "Grandpa (French (Canada)):fr-CA:local", + "Grandpa (French (France)):fr-FR:local", + "Grandpa (Italian (Italy)):it-IT:local", + "Grandpa (Japanese (Japan)):ja-JP:local", + "Grandpa (Korean (South Korea)):ko-KR:local", + "Grandpa (Portuguese (Brazil)):pt-BR:local", + "Grandpa (Chinese (China mainland)):zh-CN:local", + "Grandpa (Chinese (Taiwan)):zh-TW:local", + "Jester:en-US:local", + "Yelda:tr-TR:local", + "Ioana:ro-RO:local", + "Yuna:ko-KR:local", + "Jacques:fr-FR:local", + "Joana:pt-PT:local", + "Junior:en-US:local", + "Kanya:th-TH:local", + "Karen:en-AU:local", + "Kathy:en-US:local", + "Kyoko:ja-JP:local", + "Krzysztof (Enhanced):pl-PL:local", + "Lana:hr-HR:local", + "Laura:sk-SK:local", + "Lekha:hi-IN:local", + "Lesya:uk-UA:local", + "Linh:vi-VN:local", + "Luciana:pt-BR:local", + "Majed:ar-001:local", + "Tünde:hu-HU:local", + "Meijia:zh-TW:local", + "Melina:el-GR:local", + "Milena:ru-RU:local", + "Moira:en-IE:local", + "Mónica:es-ES:local", + "Montse:ca-ES:local", + "Nora:nb-NO:local", + "Ona ((null)):lt-LT:local", + "Ona ((null)):lt-LT:local", + "Organ:en-US:local", + "Piya:bn-IN:local", + "Paulina:es-MX:local", + "Superstar:en-US:local", + "Ralph:en-US:local", + "Reed (German (Germany)):de-DE:local", + "Reed (English (UK)):en-GB:local", + "Reed (English (US)):en-US:local", + "Reed (Spanish (Spain)):es-ES:local", + "Reed (Spanish (Mexico)):es-MX:local", + "Reed (Finnish (Finland)):fi-FI:local", + "Reed (French (Canada)):fr-CA:local", + "Reed (Italian (Italy)):it-IT:local", + "Reed (Japanese (Japan)):ja-JP:local", + "Reed (Korean (South Korea)):ko-KR:local", + "Reed (Portuguese (Brazil)):pt-BR:local", + "Reed (Chinese (China mainland)):zh-CN:local", + "Reed (Chinese (Taiwan)):zh-TW:local", + "Rishi:en-IN:local", + "Rocko (German (Germany)):de-DE:local", + "Rocko (English (UK)):en-GB:local", + "Rocko (English (US)):en-US:local", + "Rocko (Spanish (Spain)):es-ES:local", + "Rocko (Spanish (Mexico)):es-MX:local", + "Rocko (Finnish (Finland)):fi-FI:local", + "Rocko (French (Canada)):fr-CA:local", + "Rocko (French (France)):fr-FR:local", + "Rocko (Italian (Italy)):it-IT:local", + "Rocko (Japanese (Japan)):ja-JP:local", + "Rocko (Korean (South Korea)):ko-KR:local", + "Rocko (Portuguese (Brazil)):pt-BR:local", + "Rocko (Chinese (China mainland)):zh-CN:local", + "Rocko (Chinese (Taiwan)):zh-TW:local", + "Samantha:en-US:local", + "Sandy (German (Germany)):de-DE:local", + "Sandy (English (UK)):en-GB:local", + "Sandy (English (US)):en-US:local", + "Sandy (Spanish (Spain)):es-ES:local", + "Sandy (Spanish (Mexico)):es-MX:local", + "Sandy (Finnish (Finland)):fi-FI:local", + "Sandy (French (Canada)):fr-CA:local", + "Sandy (French (France)):fr-FR:local", + "Sandy (Italian (Italy)):it-IT:local", + "Sandy (Japanese (Japan)):ja-JP:local", + "Sandy (Korean (South Korea)):ko-KR:local", + "Sandy (Portuguese (Brazil)):pt-BR:local", + "Sandy (Chinese (China mainland)):zh-CN:local", + "Sandy (Chinese (Taiwan)):zh-TW:local", + "Sara:da-DK:local", + "Satu:fi-FI:local", + "Shelley (German (Germany)):de-DE:local", + "Shelley (English (UK)):en-GB:local", + "Shelley (English (US)):en-US:local", + "Shelley (Spanish (Spain)):es-ES:local", + "Shelley (Spanish (Mexico)):es-MX:local", + "Shelley (Finnish (Finland)):fi-FI:local", + "Shelley (French (Canada)):fr-CA:local", + "Shelley (French (France)):fr-FR:local", + "Shelley (Italian (Italy)):it-IT:local", + "Shelley (Japanese (Japan)):ja-JP:local", + "Shelley (Korean (South Korea)):ko-KR:local", + "Shelley (Portuguese (Brazil)):pt-BR:local", + "Shelley (Chinese (China mainland)):zh-CN:local", + "Shelley (Chinese (Taiwan)):zh-TW:local", + "Sinji:zh-HK:local", + "Tara:en-IN:local", + "Tessa:en-ZA:local", + "Thomas:fr-FR:local", + "Tina:sl-SI:local", + "Tingting:zh-CN:local", + "Trinoids:en-US:local", + "Vani:ta-IN:local", + "Whisper:en-US:local", + "Xander:nl-NL:local", + "Zarvox:en-US:local", + "Zosia:pl-PL:local", + "Zuzana:cs-CZ:local" + ] + }, + { + "navigator": { + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:150.0) Gecko/20100101 Firefox/150.0", + "platform": "MacIntel", + "hardwareConcurrency": 4, + "maxTouchPoints": 0 + }, + "screen": { + "width": 1440, + "height": 900, + "colorDepth": 30, + "availWidth": 1440, + "availHeight": 824, + "devicePixelRatio": 2 + }, + "webgl": { + "unmaskedVendor": "Intel Inc.", + "unmaskedRenderer": "Intel(R) HD Graphics 400, or similar" + } + }, + { + "navigator": { + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:151.0) Gecko/20100101 Firefox/151.0", + "platform": "MacIntel", + "hardwareConcurrency": 8, + "maxTouchPoints": 0 + }, + "screen": { + "width": 1680, + "height": 1050, + "colorDepth": 30, + "availWidth": 1680, + "availHeight": 974, + "devicePixelRatio": 2 + }, + "webgl": { + "unmaskedVendor": "Apple", + "unmaskedRenderer": "Apple M1, or similar" + } + }, + { + "navigator": { + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:149.0) Gecko/20100101 Firefox/149.0", + "platform": "MacIntel", + "hardwareConcurrency": 10, + "maxTouchPoints": 0 + }, + "screen": { + "width": 2048, + "height": 1080, + "colorDepth": 30, + "availWidth": 2048, + "availHeight": 1080, + "devicePixelRatio": 2 + }, + "webgl": { + "unmaskedVendor": "Apple", + "unmaskedRenderer": "Apple M1, or similar" + }, + "speechVoices": [ + "Albert:en-US:local", + "Alice:it-IT:local", + "Soumya:kn-IN:local", + "Alva:sv-SE:local", + "Aman:en-IN:local", + "Amélie:fr-CA:local", + "Amira:ms-MY:local", + "Anna:de-DE:local", + "Aru:kk-KZ:local", + "Bad News:en-US:local", + "Bahh:en-US:local", + "Bells:en-US:local", + "Boing:en-US:local", + "Bubbles:en-US:local", + "Carmit:he-IL:local", + "Cellos:en-US:local", + "Damayanti:id-ID:local", + "Daniel:en-GB:local", + "Daria:bg-BG:local", + "Wobble:en-US:local", + "Eddy (German (Germany)):de-DE:local", + "Eddy (English (UK)):en-GB:local", + "Eddy (English (US)):en-US:local", + "Eddy (Spanish (Spain)):es-ES:local", + "Eddy (Spanish (Mexico)):es-MX:local", + "Eddy (Finnish (Finland)):fi-FI:local", + "Eddy (French (Canada)):fr-CA:local", + "Eddy (French (France)):fr-FR:local", + "Eddy (Italian (Italy)):it-IT:local", + "Eddy (Japanese (Japan)):ja-JP:local", + "Eddy (Korean (South Korea)):ko-KR:local", + "Eddy (Portuguese (Brazil)):pt-BR:local", + "Eddy (Chinese (China mainland)):zh-CN:local", + "Eddy (Chinese (Taiwan)):zh-TW:local", + "Ellen:nl-BE:local", + "Flo (German (Germany)):de-DE:local", + "Flo (English (UK)):en-GB:local", + "Flo (English (US)):en-US:local", + "Flo (Spanish (Spain)):es-ES:local", + "Flo (Spanish (Mexico)):es-MX:local", + "Flo (Finnish (Finland)):fi-FI:local", + "Flo (French (Canada)):fr-CA:local", + "Flo (French (France)):fr-FR:local", + "Flo (Italian (Italy)):it-IT:local", + "Flo (Japanese (Japan)):ja-JP:local", + "Flo (Korean (South Korea)):ko-KR:local", + "Flo (Portuguese (Brazil)):pt-BR:local", + "Flo (Chinese (China mainland)):zh-CN:local", + "Flo (Chinese (Taiwan)):zh-TW:local", + "Fred:en-US:local", + "Geeta:te-IN:local", + "Good News:en-US:local", + "Grandma (German (Germany)):de-DE:local", + "Grandma (English (UK)):en-GB:local", + "Grandma (English (US)):en-US:local", + "Grandma (Spanish (Spain)):es-ES:local", + "Grandma (Spanish (Mexico)):es-MX:local", + "Grandma (Finnish (Finland)):fi-FI:local", + "Grandma (French (Canada)):fr-CA:local", + "Grandma (French (France)):fr-FR:local", + "Grandma (Italian (Italy)):it-IT:local", + "Grandma (Japanese (Japan)):ja-JP:local", + "Grandma (Korean (South Korea)):ko-KR:local", + "Grandma (Portuguese (Brazil)):pt-BR:local", + "Grandma (Chinese (China mainland)):zh-CN:local", + "Grandma (Chinese (Taiwan)):zh-TW:local", + "Grandpa (German (Germany)):de-DE:local", + "Grandpa (English (UK)):en-GB:local", + "Grandpa (English (US)):en-US:local", + "Grandpa (Spanish (Spain)):es-ES:local", + "Grandpa (Spanish (Mexico)):es-MX:local", + "Grandpa (Finnish (Finland)):fi-FI:local", + "Grandpa (French (Canada)):fr-CA:local", + "Grandpa (French (France)):fr-FR:local", + "Grandpa (Italian (Italy)):it-IT:local", + "Grandpa (Japanese (Japan)):ja-JP:local", + "Grandpa (Korean (South Korea)):ko-KR:local", + "Grandpa (Portuguese (Brazil)):pt-BR:local", + "Grandpa (Chinese (China mainland)):zh-CN:local", + "Grandpa (Chinese (Taiwan)):zh-TW:local", + "Jester:en-US:local", + "Ioana:ro-RO:local", + "Jacques:fr-FR:local", + "Joana:pt-PT:local", + "Junior:en-US:local", + "Kanya:th-TH:local", + "Karen:en-AU:local", + "Kathy:en-US:local", + "Kyoko:ja-JP:local", + "Lana:hr-HR:local", + "Laura:sk-SK:local", + "Lekha:hi-IN:local", + "Lesya:uk-UA:local", + "Linh:vi-VN:local", + "Luciana:pt-BR:local", + "Majed:ar-001:local", + "Tünde:hu-HU:local", + "Meijia:zh-TW:local", + "Melina:el-GR:local", + "Milena:ru-RU:local", + "Moira:en-IE:local", + "Mónica:es-ES:local", + "Montse:ca-ES:local", + "Nora:nb-NO:local", + "Ona:lt-LT:local", + "Organ:en-US:local", + "Paulina:es-MX:local", + "Piya:bn-IN:local", + "Superstar:en-US:local", + "Ralph:en-US:local", + "Reed (German (Germany)):de-DE:local", + "Reed (English (UK)):en-GB:local", + "Reed (English (US)):en-US:local", + "Reed (Spanish (Spain)):es-ES:local", + "Reed (Spanish (Mexico)):es-MX:local", + "Reed (Finnish (Finland)):fi-FI:local", + "Reed (French (Canada)):fr-CA:local", + "Reed (Italian (Italy)):it-IT:local", + "Reed (Japanese (Japan)):ja-JP:local", + "Reed (Korean (South Korea)):ko-KR:local", + "Reed (Portuguese (Brazil)):pt-BR:local", + "Reed (Chinese (China mainland)):zh-CN:local", + "Reed (Chinese (Taiwan)):zh-TW:local", + "Rishi:en-IN:local", + "Rocko (German (Germany)):de-DE:local", + "Rocko (English (UK)):en-GB:local", + "Rocko (English (US)):en-US:local", + "Rocko (Spanish (Spain)):es-ES:local", + "Rocko (Spanish (Mexico)):es-MX:local", + "Rocko (Finnish (Finland)):fi-FI:local", + "Rocko (French (Canada)):fr-CA:local", + "Rocko (French (France)):fr-FR:local", + "Rocko (Italian (Italy)):it-IT:local", + "Rocko (Japanese (Japan)):ja-JP:local", + "Rocko (Korean (South Korea)):ko-KR:local", + "Rocko (Portuguese (Brazil)):pt-BR:local", + "Rocko (Chinese (China mainland)):zh-CN:local", + "Rocko (Chinese (Taiwan)):zh-TW:local", + "Samantha:en-US:local", + "Sandy (German (Germany)):de-DE:local", + "Sandy (English (UK)):en-GB:local", + "Sandy (English (US)):en-US:local", + "Sandy (Spanish (Spain)):es-ES:local", + "Sandy (Spanish (Mexico)):es-MX:local", + "Sandy (Finnish (Finland)):fi-FI:local", + "Sandy (French (Canada)):fr-CA:local", + "Sandy (French (France)):fr-FR:local", + "Sandy (Italian (Italy)):it-IT:local", + "Sandy (Japanese (Japan)):ja-JP:local", + "Sandy (Korean (South Korea)):ko-KR:local", + "Sandy (Portuguese (Brazil)):pt-BR:local", + "Sandy (Chinese (China mainland)):zh-CN:local", + "Sandy (Chinese (Taiwan)):zh-TW:local", + "Sara:da-DK:local", + "Satu:fi-FI:local", + "Shelley (German (Germany)):de-DE:local", + "Shelley (English (UK)):en-GB:local", + "Shelley (English (US)):en-US:local", + "Shelley (Spanish (Spain)):es-ES:local", + "Shelley (Spanish (Mexico)):es-MX:local", + "Shelley (Finnish (Finland)):fi-FI:local", + "Shelley (French (Canada)):fr-CA:local", + "Shelley (French (France)):fr-FR:local", + "Shelley (Italian (Italy)):it-IT:local", + "Shelley (Japanese (Japan)):ja-JP:local", + "Shelley (Korean (South Korea)):ko-KR:local", + "Shelley (Portuguese (Brazil)):pt-BR:local", + "Shelley (Chinese (China mainland)):zh-CN:local", + "Shelley (Chinese (Taiwan)):zh-TW:local", + "Sinji:zh-HK:local", + "Tara:en-IN:local", + "Tessa:en-ZA:local", + "Thomas:fr-FR:local", + "Tina:sl-SI:local", + "Tingting:zh-CN:local", + "Trinoids:en-US:local", + "Vani:ta-IN:local", + "Whisper:en-US:local", + "Xander:nl-NL:local", + "Yelda:tr-TR:local", + "Yuna:ko-KR:local", + "Zarvox:en-US:local", + "Zosia:pl-PL:local", + "Zuzana:cs-CZ:local" + ] + }, + { + "navigator": { + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:150.0) Gecko/20100101 Firefox/150.0", + "platform": "MacIntel", + "hardwareConcurrency": 6, + "maxTouchPoints": 0 + }, + "screen": { + "width": 2560, + "height": 1080, + "colorDepth": 30, + "availWidth": 2560, + "availHeight": 957, + "devicePixelRatio": 1 + }, + "webgl": { + "unmaskedVendor": "ATI Technologies Inc.", + "unmaskedRenderer": "Radeon R9 200 Series, or similar" + }, + "speechVoices": [ + "Alex:en-US:local", + "Alice:it-IT:local", + "Alva:sv-SE:local", + "Amelie:fr-CA:local", + "Anna:de-DE:local", + "Carmit:he-IL:local", + "Damayanti:id-ID:local", + "Daniel:en-GB:local", + "Diego:es-AR:local", + "Ellen:nl-BE:local", + "Fiona:en-scotland:local", + "Fred:en-US:local", + "Ioana:ro-RO:local", + "Joana:pt-PT:local", + "Jorge:es-ES:local", + "Juan:es-MX:local", + "Kanya:th-TH:local", + "Karen:en-AU:local", + "Kyoko:ja-JP:local", + "Laura:sk-SK:local", + "Lekha:hi-IN:local", + "Luca:it-IT:local", + "Luciana:pt-BR:local", + "Maged:ar-SA:local", + "Mariska:hu-HU:local", + "Mei-Jia:zh-TW:local", + "Melina:el-GR:local", + "Milena:ru-RU:local", + "Moira:en-IE:local", + "Monica:es-ES:local", + "Nora:nb-NO:local", + "Paulina:es-MX:local", + "Rishi:en-IN:local", + "Samantha:en-US:local", + "Sara:da-DK:local", + "Satu:fi-FI:local", + "Sin-ji:zh-HK:local", + "Tessa:en-ZA:local", + "Thomas:fr-FR:local", + "Ting-Ting:zh-CN:local", + "Veena:en-IN:local", + "Victoria:en-US:local", + "Xander:nl-NL:local", + "Yelda:tr-TR:local", + "Yuna:ko-KR:local", + "Yuri:ru-RU:local", + "Zosia:pl-PL:local", + "Zuzana:cs-CZ:local" + ] + }, + { + "navigator": { + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:149.0) Gecko/20100101 Firefox/149.0", + "platform": "MacIntel", + "hardwareConcurrency": 2, + "maxTouchPoints": 0 + }, + "screen": { + "width": 1440, + "height": 900, + "colorDepth": 30, + "availWidth": 1440, + "availHeight": 900, + "devicePixelRatio": 1 + }, + "webgl": { + "unmaskedVendor": "Intel Inc.", + "unmaskedRenderer": "Intel(R) HD Graphics, or similar" + }, + "speechVoices": [ + "Alex:en-US:local", + "Alice:it-IT:local", + "Alva:sv-SE:local", + "Amelie:fr-CA:local", + "Anna:de-DE:local", + "Carmit:he-IL:local", + "Damayanti:id-ID:local", + "Daniel:en-GB:local", + "Diego:es-AR:local", + "Ellen:nl-BE:local", + "Fiona:en-scotland:local", + "Fred:en-US:local", + "Ioana:ro-RO:local", + "Joana:pt-PT:local", + "Jorge:es-ES:local", + "Juan:es-MX:local", + "Kanya:th-TH:local", + "Karen:en-AU:local", + "Kyoko:ja-JP:local", + "Laura:sk-SK:local", + "Lekha:hi-IN:local", + "Luca:it-IT:local", + "Luciana:pt-BR:local", + "Maged:ar-SA:local", + "Mariska:hu-HU:local", + "Mei-Jia:zh-TW:local", + "Melina:el-GR:local", + "Milena:ru-RU:local", + "Moira:en-IE:local", + "Monica:es-ES:local", + "Nora:nb-NO:local", + "Paulina:es-MX:local", + "Rishi:en-IN:local", + "Samantha:en-US:local", "Sara:da-DK:local", "Satu:fi-FI:local", - "Shelley (German (Germany)):de-DE:local", - "Shelley (English (UK)):en-GB:local", - "Shelley (English (US)):en-US:local", - "Shelley (Spanish (Spain)):es-ES:local", - "Shelley (Spanish (Mexico)):es-MX:local", - "Shelley (Finnish (Finland)):fi-FI:local", - "Shelley (French (Canada)):fr-CA:local", - "Shelley (French (France)):fr-FR:local", - "Shelley (Italian (Italy)):it-IT:local", - "Shelley (Japanese (Japan)):ja-JP:local", - "Shelley (Korean (South Korea)):ko-KR:local", - "Shelley (Portuguese (Brazil)):pt-BR:local", - "Shelley (Chinese (China mainland)):zh-CN:local", - "Shelley (Chinese (Taiwan)):zh-TW:local", - "Sinji:zh-HK:local", - "Tara:en-IN:local", + "Sin-ji:zh-HK:local", "Tessa:en-ZA:local", "Thomas:fr-FR:local", - "Tina:sl-SI:local", - "Tingting:zh-CN:local", - "Trinoids:en-US:local", - "Vani:ta-IN:local", - "Whisper:en-US:local", + "Ting-Ting:zh-CN:local", + "Veena:en-IN:local", + "Victoria:en-US:local", "Xander:nl-NL:local", "Yelda:tr-TR:local", "Yuna:ko-KR:local", - "Zarvox:en-US:local", + "Yuri:ru-RU:local", "Zosia:pl-PL:local", "Zuzana:cs-CZ:local" ] }, { "navigator": { - "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:152.0) Gecko/20100101 Firefox/152.0", + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:149.0) Gecko/20100101 Firefox/149.0", "platform": "MacIntel", - "hardwareConcurrency": 10, + "hardwareConcurrency": 4, "maxTouchPoints": 0 }, "screen": { - "width": 1920, - "height": 1080, + "width": 1024, + "height": 768, "colorDepth": 30, - "availWidth": 1920, - "availHeight": 1004, - "devicePixelRatio": 2 + "availWidth": 1024, + "availHeight": 768, + "devicePixelRatio": 1 }, "webgl": { - "unmaskedVendor": "Apple", - "unmaskedRenderer": "Apple M1, or similar" + "unmaskedVendor": "Intel Inc.", + "unmaskedRenderer": "Intel(R) HD Graphics 400, or similar" }, "speechVoices": [ "Albert:en-US:local", "Alice:it-IT:local", - "Soumya:kn-IN:local", "Alva:sv-SE:local", - "Aman:en-IN:local", "Amélie:fr-CA:local", "Amira:ms-MY:local", "Anna:de-DE:local", - "Aru:kk-KZ:local", "Bad News:en-US:local", "Bahh:en-US:local", "Bells:en-US:local", @@ -4200,7 +6088,6 @@ "Flo (Chinese (China mainland)):zh-CN:local", "Flo (Chinese (Taiwan)):zh-TW:local", "Fred:en-US:local", - "Geeta:te-IN:local", "Good News:en-US:local", "Grandma (German (Germany)):de-DE:local", "Grandma (English (UK)):en-GB:local", @@ -4254,10 +6141,8 @@ "Mónica:es-ES:local", "Montse:ca-ES:local", "Nora:nb-NO:local", - "Ona:lt-LT:local", "Organ:en-US:local", "Paulina:es-MX:local", - "Piya:bn-IN:local", "Superstar:en-US:local", "Ralph:en-US:local", "Reed (German (Germany)):de-DE:local", @@ -4320,7 +6205,6 @@ "Shelley (Chinese (China mainland)):zh-CN:local", "Shelley (Chinese (Taiwan)):zh-TW:local", "Sinji:zh-HK:local", - "Tara:en-IN:local", "Tessa:en-ZA:local", "Thomas:fr-FR:local", "Tina:sl-SI:local", @@ -4338,30 +6222,33 @@ }, { "navigator": { - "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:150.0) Gecko/20100101 Firefox/150.0", + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:149.0) Gecko/20100101 Firefox/149.0", "platform": "MacIntel", - "hardwareConcurrency": 6, + "hardwareConcurrency": 12, "maxTouchPoints": 0 }, "screen": { - "width": 1920, - "height": 1080, + "width": 2304, + "height": 1296, "colorDepth": 30, - "availWidth": 1920, - "availHeight": 984, - "devicePixelRatio": 1 + "availWidth": 2304, + "availHeight": 1266, + "devicePixelRatio": 2 }, "webgl": { - "unmaskedVendor": "ATI Technologies Inc.", - "unmaskedRenderer": "Radeon HD 3200 Graphics, or similar" + "unmaskedVendor": "Apple", + "unmaskedRenderer": "Apple M1, or similar" }, "speechVoices": [ "Albert:en-US:local", "Alice:it-IT:local", + "Soumya:kn-IN:local", "Alva:sv-SE:local", + "Aman:en-IN:local", "Amélie:fr-CA:local", "Amira:ms-MY:local", "Anna:de-DE:local", + "Aru:kk-KZ:local", "Bad News:en-US:local", "Bahh:en-US:local", "Bells:en-US:local", @@ -4382,7 +6269,11 @@ "Eddy (French (Canada)):fr-CA:local", "Eddy (French (France)):fr-FR:local", "Eddy (Italian (Italy)):it-IT:local", + "Eddy (Japanese (Japan)):ja-JP:local", + "Eddy (Korean (South Korea)):ko-KR:local", "Eddy (Portuguese (Brazil)):pt-BR:local", + "Eddy (Chinese (China mainland)):zh-CN:local", + "Eddy (Chinese (Taiwan)):zh-TW:local", "Ellen:nl-BE:local", "Flo (German (Germany)):de-DE:local", "Flo (English (UK)):en-GB:local", @@ -4393,8 +6284,13 @@ "Flo (French (Canada)):fr-CA:local", "Flo (French (France)):fr-FR:local", "Flo (Italian (Italy)):it-IT:local", + "Flo (Japanese (Japan)):ja-JP:local", + "Flo (Korean (South Korea)):ko-KR:local", "Flo (Portuguese (Brazil)):pt-BR:local", + "Flo (Chinese (China mainland)):zh-CN:local", + "Flo (Chinese (Taiwan)):zh-TW:local", "Fred:en-US:local", + "Geeta:te-IN:local", "Good News:en-US:local", "Grandma (German (Germany)):de-DE:local", "Grandma (English (UK)):en-GB:local", @@ -4405,7 +6301,11 @@ "Grandma (French (Canada)):fr-CA:local", "Grandma (French (France)):fr-FR:local", "Grandma (Italian (Italy)):it-IT:local", + "Grandma (Japanese (Japan)):ja-JP:local", + "Grandma (Korean (South Korea)):ko-KR:local", "Grandma (Portuguese (Brazil)):pt-BR:local", + "Grandma (Chinese (China mainland)):zh-CN:local", + "Grandma (Chinese (Taiwan)):zh-TW:local", "Grandpa (German (Germany)):de-DE:local", "Grandpa (English (UK)):en-GB:local", "Grandpa (English (US)):en-US:local", @@ -4415,7 +6315,11 @@ "Grandpa (French (Canada)):fr-CA:local", "Grandpa (French (France)):fr-FR:local", "Grandpa (Italian (Italy)):it-IT:local", + "Grandpa (Japanese (Japan)):ja-JP:local", + "Grandpa (Korean (South Korea)):ko-KR:local", "Grandpa (Portuguese (Brazil)):pt-BR:local", + "Grandpa (Chinese (China mainland)):zh-CN:local", + "Grandpa (Chinese (Taiwan)):zh-TW:local", "Jester:en-US:local", "Ioana:ro-RO:local", "Jacques:fr-FR:local", @@ -4440,8 +6344,10 @@ "Mónica:es-ES:local", "Montse:ca-ES:local", "Nora:nb-NO:local", + "Ona:lt-LT:local", "Organ:en-US:local", "Paulina:es-MX:local", + "Piya:bn-IN:local", "Superstar:en-US:local", "Ralph:en-US:local", "Reed (German (Germany)):de-DE:local", @@ -4452,7 +6358,11 @@ "Reed (Finnish (Finland)):fi-FI:local", "Reed (French (Canada)):fr-CA:local", "Reed (Italian (Italy)):it-IT:local", + "Reed (Japanese (Japan)):ja-JP:local", + "Reed (Korean (South Korea)):ko-KR:local", "Reed (Portuguese (Brazil)):pt-BR:local", + "Reed (Chinese (China mainland)):zh-CN:local", + "Reed (Chinese (Taiwan)):zh-TW:local", "Rishi:en-IN:local", "Rocko (German (Germany)):de-DE:local", "Rocko (English (UK)):en-GB:local", @@ -4463,7 +6373,11 @@ "Rocko (French (Canada)):fr-CA:local", "Rocko (French (France)):fr-FR:local", "Rocko (Italian (Italy)):it-IT:local", + "Rocko (Japanese (Japan)):ja-JP:local", + "Rocko (Korean (South Korea)):ko-KR:local", "Rocko (Portuguese (Brazil)):pt-BR:local", + "Rocko (Chinese (China mainland)):zh-CN:local", + "Rocko (Chinese (Taiwan)):zh-TW:local", "Samantha:en-US:local", "Sandy (German (Germany)):de-DE:local", "Sandy (English (UK)):en-GB:local", @@ -4474,7 +6388,11 @@ "Sandy (French (Canada)):fr-CA:local", "Sandy (French (France)):fr-FR:local", "Sandy (Italian (Italy)):it-IT:local", + "Sandy (Japanese (Japan)):ja-JP:local", + "Sandy (Korean (South Korea)):ko-KR:local", "Sandy (Portuguese (Brazil)):pt-BR:local", + "Sandy (Chinese (China mainland)):zh-CN:local", + "Sandy (Chinese (Taiwan)):zh-TW:local", "Sara:da-DK:local", "Satu:fi-FI:local", "Shelley (German (Germany)):de-DE:local", @@ -4486,12 +6404,19 @@ "Shelley (French (Canada)):fr-CA:local", "Shelley (French (France)):fr-FR:local", "Shelley (Italian (Italy)):it-IT:local", + "Shelley (Japanese (Japan)):ja-JP:local", + "Shelley (Korean (South Korea)):ko-KR:local", "Shelley (Portuguese (Brazil)):pt-BR:local", + "Shelley (Chinese (China mainland)):zh-CN:local", + "Shelley (Chinese (Taiwan)):zh-TW:local", "Sinji:zh-HK:local", + "Tara:en-IN:local", "Tessa:en-ZA:local", "Thomas:fr-FR:local", + "Tina:sl-SI:local", "Tingting:zh-CN:local", "Trinoids:en-US:local", + "Vani:ta-IN:local", "Whisper:en-US:local", "Xander:nl-NL:local", "Yelda:tr-TR:local", @@ -4503,17 +6428,17 @@ }, { "navigator": { - "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:150.0) Gecko/20100101 Firefox/150.0", + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:149.0) Gecko/20100101 Firefox/149.0", "platform": "MacIntel", - "hardwareConcurrency": 8, + "hardwareConcurrency": 10, "maxTouchPoints": 0 }, "screen": { - "width": 1710, - "height": 1112, + "width": 1512, + "height": 982, "colorDepth": 30, - "availWidth": 1710, - "availHeight": 1073, + "availWidth": 1512, + "availHeight": 860, "devicePixelRatio": 2 }, "webgl": { @@ -4525,8 +6450,7 @@ "Alice:it-IT:local", "Soumya:kn-IN:local", "Alva:sv-SE:local", - "Aman (English (India)):en-IN:local", - "Aman (English (India)):en-IN:local", + "Aman:en-IN:local", "Amélie:fr-CA:local", "Amira:ms-MY:local", "Anna:de-DE:local", @@ -4603,9 +6527,7 @@ "Grandpa (Chinese (China mainland)):zh-CN:local", "Grandpa (Chinese (Taiwan)):zh-TW:local", "Jester:en-US:local", - "Yelda:tr-TR:local", "Ioana:ro-RO:local", - "Yuna:ko-KR:local", "Jacques:fr-FR:local", "Joana:pt-PT:local", "Junior:en-US:local", @@ -4613,7 +6535,6 @@ "Karen:en-AU:local", "Kathy:en-US:local", "Kyoko:ja-JP:local", - "Krzysztof (Enhanced):pl-PL:local", "Lana:hr-HR:local", "Laura:sk-SK:local", "Lekha:hi-IN:local", @@ -4629,11 +6550,10 @@ "Mónica:es-ES:local", "Montse:ca-ES:local", "Nora:nb-NO:local", - "Ona ((null)):lt-LT:local", - "Ona ((null)):lt-LT:local", + "Ona:lt-LT:local", "Organ:en-US:local", - "Piya:bn-IN:local", "Paulina:es-MX:local", + "Piya:bn-IN:local", "Superstar:en-US:local", "Ralph:en-US:local", "Reed (German (Germany)):de-DE:local", @@ -4705,44 +6625,26 @@ "Vani:ta-IN:local", "Whisper:en-US:local", "Xander:nl-NL:local", + "Yelda:tr-TR:local", + "Yuna:ko-KR:local", "Zarvox:en-US:local", "Zosia:pl-PL:local", "Zuzana:cs-CZ:local" ] }, - { - "navigator": { - "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:151.0) Gecko/20100101 Firefox/151.0", - "platform": "MacIntel", - "hardwareConcurrency": 8, - "maxTouchPoints": 0 - }, - "screen": { - "width": 1680, - "height": 1050, - "colorDepth": 30, - "availWidth": 1680, - "availHeight": 974, - "devicePixelRatio": 2 - }, - "webgl": { - "unmaskedVendor": "Apple", - "unmaskedRenderer": "Apple M1, or similar" - } - }, { "navigator": { "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:149.0) Gecko/20100101 Firefox/149.0", "platform": "MacIntel", - "hardwareConcurrency": 10, + "hardwareConcurrency": 16, "maxTouchPoints": 0 }, "screen": { - "width": 2048, - "height": 1080, + "width": 1935, + "height": 1251, "colorDepth": 30, - "availWidth": 2048, - "availHeight": 1080, + "availWidth": 1935, + "availHeight": 1160, "devicePixelRatio": 2 }, "webgl": { @@ -4919,89 +6821,19 @@ "Shelley (Portuguese (Brazil)):pt-BR:local", "Shelley (Chinese (China mainland)):zh-CN:local", "Shelley (Chinese (Taiwan)):zh-TW:local", - "Sinji:zh-HK:local", - "Tara:en-IN:local", - "Tessa:en-ZA:local", - "Thomas:fr-FR:local", - "Tina:sl-SI:local", - "Tingting:zh-CN:local", - "Trinoids:en-US:local", - "Vani:ta-IN:local", - "Whisper:en-US:local", - "Xander:nl-NL:local", - "Yelda:tr-TR:local", - "Yuna:ko-KR:local", - "Zarvox:en-US:local", - "Zosia:pl-PL:local", - "Zuzana:cs-CZ:local" - ] - }, - { - "navigator": { - "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:149.0) Gecko/20100101 Firefox/149.0", - "platform": "MacIntel", - "hardwareConcurrency": 2, - "maxTouchPoints": 0 - }, - "screen": { - "width": 1440, - "height": 900, - "colorDepth": 30, - "availWidth": 1440, - "availHeight": 900, - "devicePixelRatio": 1 - }, - "webgl": { - "unmaskedVendor": "Intel Inc.", - "unmaskedRenderer": "Intel(R) HD Graphics, or similar" - }, - "speechVoices": [ - "Alex:en-US:local", - "Alice:it-IT:local", - "Alva:sv-SE:local", - "Amelie:fr-CA:local", - "Anna:de-DE:local", - "Carmit:he-IL:local", - "Damayanti:id-ID:local", - "Daniel:en-GB:local", - "Diego:es-AR:local", - "Ellen:nl-BE:local", - "Fiona:en-scotland:local", - "Fred:en-US:local", - "Ioana:ro-RO:local", - "Joana:pt-PT:local", - "Jorge:es-ES:local", - "Juan:es-MX:local", - "Kanya:th-TH:local", - "Karen:en-AU:local", - "Kyoko:ja-JP:local", - "Laura:sk-SK:local", - "Lekha:hi-IN:local", - "Luca:it-IT:local", - "Luciana:pt-BR:local", - "Maged:ar-SA:local", - "Mariska:hu-HU:local", - "Mei-Jia:zh-TW:local", - "Melina:el-GR:local", - "Milena:ru-RU:local", - "Moira:en-IE:local", - "Monica:es-ES:local", - "Nora:nb-NO:local", - "Paulina:es-MX:local", - "Rishi:en-IN:local", - "Samantha:en-US:local", - "Sara:da-DK:local", - "Satu:fi-FI:local", - "Sin-ji:zh-HK:local", + "Sinji:zh-HK:local", + "Tara:en-IN:local", "Tessa:en-ZA:local", "Thomas:fr-FR:local", - "Ting-Ting:zh-CN:local", - "Veena:en-IN:local", - "Victoria:en-US:local", + "Tina:sl-SI:local", + "Tingting:zh-CN:local", + "Trinoids:en-US:local", + "Vani:ta-IN:local", + "Whisper:en-US:local", "Xander:nl-NL:local", "Yelda:tr-TR:local", "Yuna:ko-KR:local", - "Yuri:ru-RU:local", + "Zarvox:en-US:local", "Zosia:pl-PL:local", "Zuzana:cs-CZ:local" ] @@ -5010,15 +6842,15 @@ "navigator": { "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:149.0) Gecko/20100101 Firefox/149.0", "platform": "MacIntel", - "hardwareConcurrency": 12, + "hardwareConcurrency": 8, "maxTouchPoints": 0 }, "screen": { - "width": 2304, - "height": 1296, + "width": 1710, + "height": 1112, "colorDepth": 30, - "availWidth": 2304, - "availHeight": 1266, + "availWidth": 1710, + "availHeight": 1112, "devicePixelRatio": 2 }, "webgl": { @@ -5216,15 +7048,35 @@ "navigator": { "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:149.0) Gecko/20100101 Firefox/149.0", "platform": "MacIntel", - "hardwareConcurrency": 10, + "hardwareConcurrency": 8, "maxTouchPoints": 0 }, "screen": { - "width": 1512, - "height": 982, + "width": 1470, + "height": 956, "colorDepth": 30, - "availWidth": 1512, - "availHeight": 860, + "availWidth": 1470, + "availHeight": 849, + "devicePixelRatio": 2 + }, + "webgl": { + "unmaskedVendor": "Apple", + "unmaskedRenderer": "Apple M1, or similar" + } + }, + { + "navigator": { + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:149.0) Gecko/20100101 Firefox/149.0", + "platform": "MacIntel", + "hardwareConcurrency": 8, + "maxTouchPoints": 0 + }, + "screen": { + "width": 1440, + "height": 900, + "colorDepth": 30, + "availWidth": 1440, + "availHeight": 824, "devicePixelRatio": 2 }, "webgl": { @@ -5234,13 +7086,10 @@ "speechVoices": [ "Albert:en-US:local", "Alice:it-IT:local", - "Soumya:kn-IN:local", "Alva:sv-SE:local", - "Aman:en-IN:local", "Amélie:fr-CA:local", "Amira:ms-MY:local", "Anna:de-DE:local", - "Aru:kk-KZ:local", "Bad News:en-US:local", "Bahh:en-US:local", "Bells:en-US:local", @@ -5282,7 +7131,6 @@ "Flo (Chinese (China mainland)):zh-CN:local", "Flo (Chinese (Taiwan)):zh-TW:local", "Fred:en-US:local", - "Geeta:te-IN:local", "Good News:en-US:local", "Grandma (German (Germany)):de-DE:local", "Grandma (English (UK)):en-GB:local", @@ -5336,10 +7184,8 @@ "Mónica:es-ES:local", "Montse:ca-ES:local", "Nora:nb-NO:local", - "Ona:lt-LT:local", "Organ:en-US:local", "Paulina:es-MX:local", - "Piya:bn-IN:local", "Superstar:en-US:local", "Ralph:en-US:local", "Reed (German (Germany)):de-DE:local", @@ -5402,7 +7248,6 @@ "Shelley (Chinese (China mainland)):zh-CN:local", "Shelley (Chinese (Taiwan)):zh-TW:local", "Sinji:zh-HK:local", - "Tara:en-IN:local", "Tessa:en-ZA:local", "Thomas:fr-FR:local", "Tina:sl-SI:local", @@ -5426,11 +7271,11 @@ "maxTouchPoints": 0 }, "screen": { - "width": 1935, - "height": 1251, + "width": 2056, + "height": 1329, "colorDepth": 30, - "availWidth": 1935, - "availHeight": 1160, + "availWidth": 2056, + "availHeight": 1285, "devicePixelRatio": 2 }, "webgl": { @@ -5440,13 +7285,10 @@ "speechVoices": [ "Albert:en-US:local", "Alice:it-IT:local", - "Soumya:kn-IN:local", "Alva:sv-SE:local", - "Aman:en-IN:local", "Amélie:fr-CA:local", "Amira:ms-MY:local", "Anna:de-DE:local", - "Aru:kk-KZ:local", "Bad News:en-US:local", "Bahh:en-US:local", "Bells:en-US:local", @@ -5467,11 +7309,7 @@ "Eddy (French (Canada)):fr-CA:local", "Eddy (French (France)):fr-FR:local", "Eddy (Italian (Italy)):it-IT:local", - "Eddy (Japanese (Japan)):ja-JP:local", - "Eddy (Korean (South Korea)):ko-KR:local", "Eddy (Portuguese (Brazil)):pt-BR:local", - "Eddy (Chinese (China mainland)):zh-CN:local", - "Eddy (Chinese (Taiwan)):zh-TW:local", "Ellen:nl-BE:local", "Flo (German (Germany)):de-DE:local", "Flo (English (UK)):en-GB:local", @@ -5482,13 +7320,8 @@ "Flo (French (Canada)):fr-CA:local", "Flo (French (France)):fr-FR:local", "Flo (Italian (Italy)):it-IT:local", - "Flo (Japanese (Japan)):ja-JP:local", - "Flo (Korean (South Korea)):ko-KR:local", "Flo (Portuguese (Brazil)):pt-BR:local", - "Flo (Chinese (China mainland)):zh-CN:local", - "Flo (Chinese (Taiwan)):zh-TW:local", "Fred:en-US:local", - "Geeta:te-IN:local", "Good News:en-US:local", "Grandma (German (Germany)):de-DE:local", "Grandma (English (UK)):en-GB:local", @@ -5499,11 +7332,7 @@ "Grandma (French (Canada)):fr-CA:local", "Grandma (French (France)):fr-FR:local", "Grandma (Italian (Italy)):it-IT:local", - "Grandma (Japanese (Japan)):ja-JP:local", - "Grandma (Korean (South Korea)):ko-KR:local", "Grandma (Portuguese (Brazil)):pt-BR:local", - "Grandma (Chinese (China mainland)):zh-CN:local", - "Grandma (Chinese (Taiwan)):zh-TW:local", "Grandpa (German (Germany)):de-DE:local", "Grandpa (English (UK)):en-GB:local", "Grandpa (English (US)):en-US:local", @@ -5513,11 +7342,7 @@ "Grandpa (French (Canada)):fr-CA:local", "Grandpa (French (France)):fr-FR:local", "Grandpa (Italian (Italy)):it-IT:local", - "Grandpa (Japanese (Japan)):ja-JP:local", - "Grandpa (Korean (South Korea)):ko-KR:local", "Grandpa (Portuguese (Brazil)):pt-BR:local", - "Grandpa (Chinese (China mainland)):zh-CN:local", - "Grandpa (Chinese (Taiwan)):zh-TW:local", "Jester:en-US:local", "Ioana:ro-RO:local", "Jacques:fr-FR:local", @@ -5542,10 +7367,8 @@ "Mónica:es-ES:local", "Montse:ca-ES:local", "Nora:nb-NO:local", - "Ona:lt-LT:local", "Organ:en-US:local", "Paulina:es-MX:local", - "Piya:bn-IN:local", "Superstar:en-US:local", "Ralph:en-US:local", "Reed (German (Germany)):de-DE:local", @@ -5556,11 +7379,7 @@ "Reed (Finnish (Finland)):fi-FI:local", "Reed (French (Canada)):fr-CA:local", "Reed (Italian (Italy)):it-IT:local", - "Reed (Japanese (Japan)):ja-JP:local", - "Reed (Korean (South Korea)):ko-KR:local", "Reed (Portuguese (Brazil)):pt-BR:local", - "Reed (Chinese (China mainland)):zh-CN:local", - "Reed (Chinese (Taiwan)):zh-TW:local", "Rishi:en-IN:local", "Rocko (German (Germany)):de-DE:local", "Rocko (English (UK)):en-GB:local", @@ -5571,11 +7390,7 @@ "Rocko (French (Canada)):fr-CA:local", "Rocko (French (France)):fr-FR:local", "Rocko (Italian (Italy)):it-IT:local", - "Rocko (Japanese (Japan)):ja-JP:local", - "Rocko (Korean (South Korea)):ko-KR:local", "Rocko (Portuguese (Brazil)):pt-BR:local", - "Rocko (Chinese (China mainland)):zh-CN:local", - "Rocko (Chinese (Taiwan)):zh-TW:local", "Samantha:en-US:local", "Sandy (German (Germany)):de-DE:local", "Sandy (English (UK)):en-GB:local", @@ -5586,11 +7401,7 @@ "Sandy (French (Canada)):fr-CA:local", "Sandy (French (France)):fr-FR:local", "Sandy (Italian (Italy)):it-IT:local", - "Sandy (Japanese (Japan)):ja-JP:local", - "Sandy (Korean (South Korea)):ko-KR:local", "Sandy (Portuguese (Brazil)):pt-BR:local", - "Sandy (Chinese (China mainland)):zh-CN:local", - "Sandy (Chinese (Taiwan)):zh-TW:local", "Sara:da-DK:local", "Satu:fi-FI:local", "Shelley (German (Germany)):de-DE:local", @@ -5602,19 +7413,12 @@ "Shelley (French (Canada)):fr-CA:local", "Shelley (French (France)):fr-FR:local", "Shelley (Italian (Italy)):it-IT:local", - "Shelley (Japanese (Japan)):ja-JP:local", - "Shelley (Korean (South Korea)):ko-KR:local", "Shelley (Portuguese (Brazil)):pt-BR:local", - "Shelley (Chinese (China mainland)):zh-CN:local", - "Shelley (Chinese (Taiwan)):zh-TW:local", "Sinji:zh-HK:local", - "Tara:en-IN:local", "Tessa:en-ZA:local", "Thomas:fr-FR:local", - "Tina:sl-SI:local", "Tingting:zh-CN:local", "Trinoids:en-US:local", - "Vani:ta-IN:local", "Whisper:en-US:local", "Xander:nl-NL:local", "Yelda:tr-TR:local", @@ -5632,11 +7436,11 @@ "maxTouchPoints": 0 }, "screen": { - "width": 1710, - "height": 1112, + "width": 1280, + "height": 720, "colorDepth": 30, - "availWidth": 1710, - "availHeight": 1112, + "availWidth": 1280, + "availHeight": 720, "devicePixelRatio": 2 }, "webgl": { @@ -5661,7 +7465,7 @@ "Carmit:he-IL:local", "Cellos:en-US:local", "Damayanti:id-ID:local", - "Daniel:en-GB:local", + "Daniel (English (UK)):en-GB:local", "Daria:bg-BG:local", "Wobble:en-US:local", "Eddy (German (Germany)):de-DE:local", @@ -5838,32 +7642,12 @@ "maxTouchPoints": 0 }, "screen": { - "width": 1470, - "height": 956, - "colorDepth": 30, - "availWidth": 1470, - "availHeight": 849, - "devicePixelRatio": 2 - }, - "webgl": { - "unmaskedVendor": "Apple", - "unmaskedRenderer": "Apple M1, or similar" - } - }, - { - "navigator": { - "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:149.0) Gecko/20100101 Firefox/149.0", - "platform": "MacIntel", - "hardwareConcurrency": 8, - "maxTouchPoints": 0 - }, - "screen": { - "width": 1440, - "height": 900, + "width": 5120, + "height": 1440, "colorDepth": 30, - "availWidth": 1440, - "availHeight": 824, - "devicePixelRatio": 2 + "availWidth": 5120, + "availHeight": 1364, + "devicePixelRatio": 1 }, "webgl": { "unmaskedVendor": "Apple", @@ -5872,10 +7656,13 @@ "speechVoices": [ "Albert:en-US:local", "Alice:it-IT:local", + "Soumya:kn-IN:local", "Alva:sv-SE:local", + "Aman:en-IN:local", "Amélie:fr-CA:local", "Amira:ms-MY:local", "Anna:de-DE:local", + "Aru:kk-KZ:local", "Bad News:en-US:local", "Bahh:en-US:local", "Bells:en-US:local", @@ -5917,6 +7704,7 @@ "Flo (Chinese (China mainland)):zh-CN:local", "Flo (Chinese (Taiwan)):zh-TW:local", "Fred:en-US:local", + "Geeta:te-IN:local", "Good News:en-US:local", "Grandma (German (Germany)):de-DE:local", "Grandma (English (UK)):en-GB:local", @@ -5970,8 +7758,10 @@ "Mónica:es-ES:local", "Montse:ca-ES:local", "Nora:nb-NO:local", + "Ona:lt-LT:local", "Organ:en-US:local", "Paulina:es-MX:local", + "Piya:bn-IN:local", "Superstar:en-US:local", "Ralph:en-US:local", "Reed (German (Germany)):de-DE:local", @@ -6034,6 +7824,7 @@ "Shelley (Chinese (China mainland)):zh-CN:local", "Shelley (Chinese (Taiwan)):zh-TW:local", "Sinji:zh-HK:local", + "Tara:en-IN:local", "Tessa:en-ZA:local", "Thomas:fr-FR:local", "Tina:sl-SI:local", @@ -6053,20 +7844,20 @@ "navigator": { "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:149.0) Gecko/20100101 Firefox/149.0", "platform": "MacIntel", - "hardwareConcurrency": 16, + "hardwareConcurrency": 6, "maxTouchPoints": 0 }, "screen": { - "width": 2056, - "height": 1329, + "width": 1536, + "height": 960, "colorDepth": 30, - "availWidth": 2056, - "availHeight": 1285, + "availWidth": 1536, + "availHeight": 935, "devicePixelRatio": 2 }, "webgl": { - "unmaskedVendor": "Apple", - "unmaskedRenderer": "Apple M1, or similar" + "unmaskedVendor": "Intel Inc.", + "unmaskedRenderer": "Intel(R) HD Graphics 400, or similar" }, "speechVoices": [ "Albert:en-US:local", @@ -6218,15 +8009,15 @@ "navigator": { "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:149.0) Gecko/20100101 Firefox/149.0", "platform": "MacIntel", - "hardwareConcurrency": 8, + "hardwareConcurrency": 10, "maxTouchPoints": 0 }, "screen": { - "width": 1280, - "height": 720, + "width": 1800, + "height": 1169, "colorDepth": 30, - "availWidth": 1280, - "availHeight": 720, + "availWidth": 1800, + "availHeight": 1061, "devicePixelRatio": 2 }, "webgl": { @@ -6236,13 +8027,10 @@ "speechVoices": [ "Albert:en-US:local", "Alice:it-IT:local", - "Soumya:kn-IN:local", "Alva:sv-SE:local", - "Aman:en-IN:local", "Amélie:fr-CA:local", "Amira:ms-MY:local", "Anna:de-DE:local", - "Aru:kk-KZ:local", "Bad News:en-US:local", "Bahh:en-US:local", "Bells:en-US:local", @@ -6251,7 +8039,7 @@ "Carmit:he-IL:local", "Cellos:en-US:local", "Damayanti:id-ID:local", - "Daniel (English (UK)):en-GB:local", + "Daniel:en-GB:local", "Daria:bg-BG:local", "Wobble:en-US:local", "Eddy (German (Germany)):de-DE:local", @@ -6284,7 +8072,6 @@ "Flo (Chinese (China mainland)):zh-CN:local", "Flo (Chinese (Taiwan)):zh-TW:local", "Fred:en-US:local", - "Geeta:te-IN:local", "Good News:en-US:local", "Grandma (German (Germany)):de-DE:local", "Grandma (English (UK)):en-GB:local", @@ -6338,10 +8125,8 @@ "Mónica:es-ES:local", "Montse:ca-ES:local", "Nora:nb-NO:local", - "Ona:lt-LT:local", "Organ:en-US:local", "Paulina:es-MX:local", - "Piya:bn-IN:local", "Superstar:en-US:local", "Ralph:en-US:local", "Reed (German (Germany)):de-DE:local", @@ -6404,7 +8189,6 @@ "Shelley (Chinese (China mainland)):zh-CN:local", "Shelley (Chinese (Taiwan)):zh-TW:local", "Sinji:zh-HK:local", - "Tara:en-IN:local", "Tessa:en-ZA:local", "Thomas:fr-FR:local", "Tina:sl-SI:local", @@ -6424,31 +8208,48 @@ "navigator": { "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:149.0) Gecko/20100101 Firefox/149.0", "platform": "MacIntel", - "hardwareConcurrency": 8, + "hardwareConcurrency": 6, "maxTouchPoints": 0 }, "screen": { - "width": 5120, + "width": 2560, "height": 1440, "colorDepth": 30, - "availWidth": 5120, - "availHeight": 1364, - "devicePixelRatio": 1 + "availWidth": 2560, + "availHeight": 1415, + "devicePixelRatio": 2 }, "webgl": { - "unmaskedVendor": "Apple", - "unmaskedRenderer": "Apple M1, or similar" + "unmaskedVendor": "ATI Technologies Inc.", + "unmaskedRenderer": "Radeon R9 200 Series, or similar" + } + }, + { + "navigator": { + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:149.0) Gecko/20100101 Firefox/149.0", + "platform": "MacIntel", + "hardwareConcurrency": 8, + "maxTouchPoints": 0 + }, + "screen": { + "width": 2133, + "height": 1200, + "colorDepth": 30, + "availWidth": 2133, + "availHeight": 1179, + "devicePixelRatio": 2.4 + }, + "webgl": { + "unmaskedVendor": "ATI Technologies Inc.", + "unmaskedRenderer": "Radeon R9 200 Series, or similar" }, "speechVoices": [ "Albert:en-US:local", "Alice:it-IT:local", - "Soumya:kn-IN:local", "Alva:sv-SE:local", - "Aman:en-IN:local", "Amélie:fr-CA:local", "Amira:ms-MY:local", "Anna:de-DE:local", - "Aru:kk-KZ:local", "Bad News:en-US:local", "Bahh:en-US:local", "Bells:en-US:local", @@ -6490,7 +8291,6 @@ "Flo (Chinese (China mainland)):zh-CN:local", "Flo (Chinese (Taiwan)):zh-TW:local", "Fred:en-US:local", - "Geeta:te-IN:local", "Good News:en-US:local", "Grandma (German (Germany)):de-DE:local", "Grandma (English (UK)):en-GB:local", @@ -6544,10 +8344,8 @@ "Mónica:es-ES:local", "Montse:ca-ES:local", "Nora:nb-NO:local", - "Ona:lt-LT:local", "Organ:en-US:local", "Paulina:es-MX:local", - "Piya:bn-IN:local", "Superstar:en-US:local", "Ralph:en-US:local", "Reed (German (Germany)):de-DE:local", @@ -6610,7 +8408,6 @@ "Shelley (Chinese (China mainland)):zh-CN:local", "Shelley (Chinese (Taiwan)):zh-TW:local", "Sinji:zh-HK:local", - "Tara:en-IN:local", "Tessa:en-ZA:local", "Thomas:fr-FR:local", "Tina:sl-SI:local", @@ -6630,20 +8427,20 @@ "navigator": { "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:149.0) Gecko/20100101 Firefox/149.0", "platform": "MacIntel", - "hardwareConcurrency": 10, + "hardwareConcurrency": 6, "maxTouchPoints": 0 }, "screen": { - "width": 1800, - "height": 1169, + "width": 2048, + "height": 1152, "colorDepth": 30, - "availWidth": 1800, - "availHeight": 1061, + "availWidth": 2048, + "availHeight": 1026, "devicePixelRatio": 2 }, "webgl": { - "unmaskedVendor": "Apple", - "unmaskedRenderer": "Apple M1, or similar" + "unmaskedVendor": "ATI Technologies Inc.", + "unmaskedRenderer": "Radeon R9 200 Series, or similar" }, "speechVoices": [ "Albert:en-US:local", @@ -6825,6 +8622,26 @@ "Zuzana:cs-CZ:local" ] }, + { + "navigator": { + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:149.0) Gecko/20100101 Firefox/149.0", + "platform": "MacIntel", + "hardwareConcurrency": 2, + "maxTouchPoints": 0 + }, + "screen": { + "width": 1366, + "height": 768, + "colorDepth": 30, + "availWidth": 1366, + "availHeight": 670, + "devicePixelRatio": 1 + }, + "webgl": { + "unmaskedVendor": "Intel Inc.", + "unmaskedRenderer": "Intel(R) HD Graphics 400, or similar" + } + }, { "navigator": { "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:149.0) Gecko/20100101 Firefox/149.0", diff --git a/pythonlib/camoufox/fingerprint-presets.json b/pythonlib/camoufox/fingerprint-presets.json index f44823518..9f808084c 100644 --- a/pythonlib/camoufox/fingerprint-presets.json +++ b/pythonlib/camoufox/fingerprint-presets.json @@ -3,6 +3,171 @@ "generated_at": "2026-03-11T00:45:14.521Z", "presets": { "macos": [ + { + "navigator": { + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:148.0) Gecko/20100101 Firefox/148.0", + "platform": "MacIntel", + "hardwareConcurrency": 4, + "maxTouchPoints": 0 + }, + "screen": { + "width": 1440, + "height": 900, + "colorDepth": 30, + "availWidth": 1440, + "availHeight": 900, + "devicePixelRatio": 2 + }, + "webgl": { + "unmaskedVendor": "Intel Inc.", + "unmaskedRenderer": "Intel(R) HD Graphics 400, or similar" + }, + "speechVoices": [ + "Albert:en-US:local", + "Alice:it-IT:local", + "Alva:sv-SE:local", + "Amélie:fr-CA:local", + "Amira:ms-MY:local", + "Anna:de-DE:local", + "Bad News:en-US:local", + "Bahh:en-US:local", + "Bells:en-US:local", + "Boing:en-US:local", + "Bubbles:en-US:local", + "Carmit:he-IL:local", + "Cellos:en-US:local", + "Damayanti:id-ID:local", + "Daniel:en-GB:local", + "Daria:bg-BG:local", + "Wobble:en-US:local", + "Eddy (German (Germany)):de-DE:local", + "Eddy (English (U.K.)):en-GB:local", + "Eddy (English (U.S.)):en-US:local", + "Eddy (Spanish (Spain)):es-ES:local", + "Eddy (Spanish (Mexico)):es-MX:local", + "Eddy (Finnish (Finland)):fi-FI:local", + "Eddy (French (Canada)):fr-CA:local", + "Eddy (French (France)):fr-FR:local", + "Eddy (Italian (Italy)):it-IT:local", + "Eddy (Portuguese (Brazil)):pt-BR:local", + "Ellen:nl-BE:local", + "Flo (German (Germany)):de-DE:local", + "Flo (English (U.K.)):en-GB:local", + "Flo (English (U.S.)):en-US:local", + "Flo (Spanish (Spain)):es-ES:local", + "Flo (Spanish (Mexico)):es-MX:local", + "Flo (Finnish (Finland)):fi-FI:local", + "Flo (French (Canada)):fr-CA:local", + "Flo (French (France)):fr-FR:local", + "Flo (Italian (Italy)):it-IT:local", + "Flo (Portuguese (Brazil)):pt-BR:local", + "Fred:en-US:local", + "Good News:en-US:local", + "Grandma (German (Germany)):de-DE:local", + "Grandma (English (U.K.)):en-GB:local", + "Grandma (English (U.S.)):en-US:local", + "Grandma (Spanish (Spain)):es-ES:local", + "Grandma (Spanish (Mexico)):es-MX:local", + "Grandma (Finnish (Finland)):fi-FI:local", + "Grandma (French (Canada)):fr-CA:local", + "Grandma (French (France)):fr-FR:local", + "Grandma (Italian (Italy)):it-IT:local", + "Grandma (Portuguese (Brazil)):pt-BR:local", + "Grandpa (German (Germany)):de-DE:local", + "Grandpa (English (U.K.)):en-GB:local", + "Grandpa (English (U.S.)):en-US:local", + "Grandpa (Spanish (Spain)):es-ES:local", + "Grandpa (Spanish (Mexico)):es-MX:local", + "Grandpa (Finnish (Finland)):fi-FI:local", + "Grandpa (French (Canada)):fr-CA:local", + "Grandpa (French (France)):fr-FR:local", + "Grandpa (Italian (Italy)):it-IT:local", + "Grandpa (Portuguese (Brazil)):pt-BR:local", + "Jester:en-US:local", + "Ioana:ro-RO:local", + "Jacques:fr-FR:local", + "Joana:pt-PT:local", + "Junior:en-US:local", + "Kanya:th-TH:local", + "Karen:en-AU:local", + "Kathy:en-US:local", + "Kyoko:ja-JP:local", + "Lana:hr-HR:local", + "Laura:sk-SK:local", + "Lekha:hi-IN:local", + "Lesya:uk-UA:local", + "Linh:vi-VN:local", + "Luciana:pt-BR:local", + "Majed:ar-001:local", + "Tünde:hu-HU:local", + "Meijia:zh-TW:local", + "Melina:el-GR:local", + "Milena:ru-RU:local", + "Moira:en-IE:local", + "Mónica:es-ES:local", + "Montse:ca-ES:local", + "Nora:nb-NO:local", + "Organ:en-US:local", + "Paulina:es-MX:local", + "Superstar:en-US:local", + "Ralph:en-US:local", + "Reed (German (Germany)):de-DE:local", + "Reed (English (U.K.)):en-GB:local", + "Reed (English (U.S.)):en-US:local", + "Reed (Spanish (Spain)):es-ES:local", + "Reed (Spanish (Mexico)):es-MX:local", + "Reed (Finnish (Finland)):fi-FI:local", + "Reed (French (Canada)):fr-CA:local", + "Reed (Italian (Italy)):it-IT:local", + "Reed (Portuguese (Brazil)):pt-BR:local", + "Rishi:en-IN:local", + "Rocko (German (Germany)):de-DE:local", + "Rocko (English (U.K.)):en-GB:local", + "Rocko (English (U.S.)):en-US:local", + "Rocko (Spanish (Spain)):es-ES:local", + "Rocko (Spanish (Mexico)):es-MX:local", + "Rocko (Finnish (Finland)):fi-FI:local", + "Rocko (French (Canada)):fr-CA:local", + "Rocko (French (France)):fr-FR:local", + "Rocko (Italian (Italy)):it-IT:local", + "Rocko (Portuguese (Brazil)):pt-BR:local", + "Samantha:en-US:local", + "Sandy (German (Germany)):de-DE:local", + "Sandy (English (U.K.)):en-GB:local", + "Sandy (English (U.S.)):en-US:local", + "Sandy (Spanish (Spain)):es-ES:local", + "Sandy (Spanish (Mexico)):es-MX:local", + "Sandy (Finnish (Finland)):fi-FI:local", + "Sandy (French (Canada)):fr-CA:local", + "Sandy (French (France)):fr-FR:local", + "Sandy (Italian (Italy)):it-IT:local", + "Sandy (Portuguese (Brazil)):pt-BR:local", + "Sara:da-DK:local", + "Satu:fi-FI:local", + "Shelley (German (Germany)):de-DE:local", + "Shelley (English (U.K.)):en-GB:local", + "Shelley (English (U.S.)):en-US:local", + "Shelley (Spanish (Spain)):es-ES:local", + "Shelley (Spanish (Mexico)):es-MX:local", + "Shelley (Finnish (Finland)):fi-FI:local", + "Shelley (French (Canada)):fr-CA:local", + "Shelley (French (France)):fr-FR:local", + "Shelley (Italian (Italy)):it-IT:local", + "Shelley (Portuguese (Brazil)):pt-BR:local", + "Sinji:zh-HK:local", + "Tessa:en-ZA:local", + "Thomas:fr-FR:local", + "Tingting:zh-CN:local", + "Trinoids:en-US:local", + "Whisper:en-US:local", + "Xander:nl-NL:local", + "Yelda:tr-TR:local", + "Yuna:ko-KR:local", + "Zarvox:en-US:local", + "Zosia:pl-PL:local", + "Zuzana:cs-CZ:local" + ] + }, { "navigator": { "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:148.0) Gecko/20100101 Firefox/148.0", @@ -210,63 +375,392 @@ }, { "navigator": { - "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:147.0) Gecko/20100101 Firefox/147.0", + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:148.0) Gecko/20100101 Firefox/148.0", "platform": "MacIntel", - "hardwareConcurrency": 4, + "hardwareConcurrency": 2, "maxTouchPoints": 0 }, "screen": { - "width": 1440, - "height": 900, + "width": 1920, + "height": 1080, "colorDepth": 30, - "availWidth": 1440, - "availHeight": 900, - "devicePixelRatio": 2 + "availWidth": 1920, + "availHeight": 985 }, "webgl": { "unmaskedVendor": "Intel Inc.", - "unmaskedRenderer": "Intel(R) HD Graphics, or similar" + "unmaskedRenderer": "Intel(R) HD Graphics 400, or similar" }, "speechVoices": [ - "Alex:en-US:local", + "Albert:en-US:local", "Alice:it-IT:local", "Alva:sv-SE:local", - "Amelie:fr-CA:local", + "Amélie:fr-CA:local", + "Amira:ms-MY:local", "Anna:de-DE:local", + "Bad News:en-US:local", + "Bahh:en-US:local", + "Bells:en-US:local", + "Boing:en-US:local", + "Bubbles:en-US:local", "Carmit:he-IL:local", + "Cellos:en-US:local", "Damayanti:id-ID:local", "Daniel:en-GB:local", - "Diego:es-AR:local", + "Daria:bg-BG:local", + "Wobble:en-US:local", + "Eddy (German (Germany)):de-DE:local", + "Eddy (English (UK)):en-GB:local", + "Eddy (English (US)):en-US:local", + "Eddy (Spanish (Spain)):es-ES:local", + "Eddy (Spanish (Mexico)):es-MX:local", + "Eddy (Finnish (Finland)):fi-FI:local", + "Eddy (French (Canada)):fr-CA:local", + "Eddy (French (France)):fr-FR:local", + "Eddy (Italian (Italy)):it-IT:local", + "Eddy (Portuguese (Brazil)):pt-BR:local", "Ellen:nl-BE:local", - "Fiona:en-scotland:local", + "Flo (German (Germany)):de-DE:local", + "Flo (English (UK)):en-GB:local", + "Flo (English (US)):en-US:local", + "Flo (Spanish (Spain)):es-ES:local", + "Flo (Spanish (Mexico)):es-MX:local", + "Flo (Finnish (Finland)):fi-FI:local", + "Flo (French (Canada)):fr-CA:local", + "Flo (French (France)):fr-FR:local", + "Flo (Italian (Italy)):it-IT:local", + "Flo (Portuguese (Brazil)):pt-BR:local", "Fred:en-US:local", + "Good News:en-US:local", + "Grandma (German (Germany)):de-DE:local", + "Grandma (English (UK)):en-GB:local", + "Grandma (English (US)):en-US:local", + "Grandma (Spanish (Spain)):es-ES:local", + "Grandma (Spanish (Mexico)):es-MX:local", + "Grandma (Finnish (Finland)):fi-FI:local", + "Grandma (French (Canada)):fr-CA:local", + "Grandma (French (France)):fr-FR:local", + "Grandma (Italian (Italy)):it-IT:local", + "Grandma (Portuguese (Brazil)):pt-BR:local", + "Grandpa (German (Germany)):de-DE:local", + "Grandpa (English (UK)):en-GB:local", + "Grandpa (English (US)):en-US:local", + "Grandpa (Spanish (Spain)):es-ES:local", + "Grandpa (Spanish (Mexico)):es-MX:local", + "Grandpa (Finnish (Finland)):fi-FI:local", + "Grandpa (French (Canada)):fr-CA:local", + "Grandpa (French (France)):fr-FR:local", + "Grandpa (Italian (Italy)):it-IT:local", + "Grandpa (Portuguese (Brazil)):pt-BR:local", + "Jester:en-US:local", "Ioana:ro-RO:local", + "Jacques:fr-FR:local", "Joana:pt-PT:local", - "Jorge:es-ES:local", - "Juan:es-MX:local", + "Junior:en-US:local", "Kanya:th-TH:local", "Karen:en-AU:local", + "Kathy:en-US:local", "Kyoko:ja-JP:local", + "Lana:hr-HR:local", "Laura:sk-SK:local", "Lekha:hi-IN:local", - "Luca:it-IT:local", + "Lesya:uk-UA:local", + "Linh:vi-VN:local", "Luciana:pt-BR:local", - "Maged:ar-SA:local", - "Mariska:hu-HU:local", - "Mei-Jia:zh-TW:local", + "Majed:ar-001:local", + "Tünde:hu-HU:local", + "Meijia:zh-TW:local", "Melina:el-GR:local", "Milena:ru-RU:local", "Moira:en-IE:local", - "Monica:es-ES:local", + "Mónica:es-ES:local", + "Montse:ca-ES:local", "Nora:nb-NO:local", + "Organ:en-US:local", "Paulina:es-MX:local", + "Superstar:en-US:local", + "Ralph:en-US:local", + "Reed (German (Germany)):de-DE:local", + "Reed (English (UK)):en-GB:local", + "Reed (English (US)):en-US:local", + "Reed (Spanish (Spain)):es-ES:local", + "Reed (Spanish (Mexico)):es-MX:local", + "Reed (Finnish (Finland)):fi-FI:local", + "Reed (French (Canada)):fr-CA:local", + "Reed (Italian (Italy)):it-IT:local", + "Reed (Portuguese (Brazil)):pt-BR:local", "Rishi:en-IN:local", - "Samantha:en-US:local", - "Sara:da-DK:local", - "Satu:fi-FI:local", - "Sin-ji:zh-HK:local", - "Tessa:en-ZA:local", - "Thomas:fr-FR:local", + "Rocko (German (Germany)):de-DE:local", + "Rocko (English (UK)):en-GB:local", + "Rocko (English (US)):en-US:local", + "Rocko (Spanish (Spain)):es-ES:local", + "Rocko (Spanish (Mexico)):es-MX:local", + "Rocko (Finnish (Finland)):fi-FI:local", + "Rocko (French (Canada)):fr-CA:local", + "Rocko (French (France)):fr-FR:local", + "Rocko (Italian (Italy)):it-IT:local", + "Rocko (Portuguese (Brazil)):pt-BR:local", + "Samantha:en-US:local", + "Sandy (German (Germany)):de-DE:local", + "Sandy (English (UK)):en-GB:local", + "Sandy (English (US)):en-US:local", + "Sandy (Spanish (Spain)):es-ES:local", + "Sandy (Spanish (Mexico)):es-MX:local", + "Sandy (Finnish (Finland)):fi-FI:local", + "Sandy (French (Canada)):fr-CA:local", + "Sandy (French (France)):fr-FR:local", + "Sandy (Italian (Italy)):it-IT:local", + "Sandy (Portuguese (Brazil)):pt-BR:local", + "Sara:da-DK:local", + "Satu:fi-FI:local", + "Shelley (German (Germany)):de-DE:local", + "Shelley (English (UK)):en-GB:local", + "Shelley (English (US)):en-US:local", + "Shelley (Spanish (Spain)):es-ES:local", + "Shelley (Spanish (Mexico)):es-MX:local", + "Shelley (Finnish (Finland)):fi-FI:local", + "Shelley (French (Canada)):fr-CA:local", + "Shelley (French (France)):fr-FR:local", + "Shelley (Italian (Italy)):it-IT:local", + "Shelley (Portuguese (Brazil)):pt-BR:local", + "Sinji:zh-HK:local", + "Tessa:en-ZA:local", + "Thomas:fr-FR:local", + "Tingting:zh-CN:local", + "Trinoids:en-US:local", + "Whisper:en-US:local", + "Xander:nl-NL:local", + "Yelda:tr-TR:local", + "Yuna:ko-KR:local", + "Zarvox:en-US:local", + "Zosia:pl-PL:local", + "Zuzana:cs-CZ:local" + ] + }, + { + "navigator": { + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:148.0) Gecko/20100101 Firefox/148.0", + "platform": "MacIntel", + "hardwareConcurrency": 4, + "maxTouchPoints": 0 + }, + "screen": { + "width": 2048, + "height": 1152, + "colorDepth": 30, + "availWidth": 2048, + "availHeight": 1045, + "devicePixelRatio": 2 + }, + "webgl": { + "unmaskedVendor": "ATI Technologies Inc.", + "unmaskedRenderer": "Radeon R9 200 Series, or similar" + }, + "speechVoices": [ + "Albert:en-US:local", + "Alice:it-IT:local", + "Alva:sv-SE:local", + "Amélie:fr-CA:local", + "Amira:ms-MY:local", + "Anna:de-DE:local", + "Bad News:en-US:local", + "Bahh:en-US:local", + "Bells:en-US:local", + "Boing:en-US:local", + "Bubbles:en-US:local", + "Carmit:he-IL:local", + "Cellos:en-US:local", + "Damayanti:id-ID:local", + "Daniel:en-GB:local", + "Daria:bg-BG:local", + "Wobble:en-US:local", + "Eddy (German (Germany)):de-DE:local", + "Eddy (English (U.K.)):en-GB:local", + "Eddy (English (U.S.)):en-US:local", + "Eddy (Spanish (Spain)):es-ES:local", + "Eddy (Spanish (Mexico)):es-MX:local", + "Eddy (Finnish (Finland)):fi-FI:local", + "Eddy (French (Canada)):fr-CA:local", + "Eddy (French (France)):fr-FR:local", + "Eddy (Italian (Italy)):it-IT:local", + "Eddy (Portuguese (Brazil)):pt-BR:local", + "Ellen:nl-BE:local", + "Flo (German (Germany)):de-DE:local", + "Flo (English (U.K.)):en-GB:local", + "Flo (English (U.S.)):en-US:local", + "Flo (Spanish (Spain)):es-ES:local", + "Flo (Spanish (Mexico)):es-MX:local", + "Flo (Finnish (Finland)):fi-FI:local", + "Flo (French (Canada)):fr-CA:local", + "Flo (French (France)):fr-FR:local", + "Flo (Italian (Italy)):it-IT:local", + "Flo (Portuguese (Brazil)):pt-BR:local", + "Fred:en-US:local", + "Good News:en-US:local", + "Grandma (German (Germany)):de-DE:local", + "Grandma (English (U.K.)):en-GB:local", + "Grandma (English (U.S.)):en-US:local", + "Grandma (Spanish (Spain)):es-ES:local", + "Grandma (Spanish (Mexico)):es-MX:local", + "Grandma (Finnish (Finland)):fi-FI:local", + "Grandma (French (Canada)):fr-CA:local", + "Grandma (French (France)):fr-FR:local", + "Grandma (Italian (Italy)):it-IT:local", + "Grandma (Portuguese (Brazil)):pt-BR:local", + "Grandpa (German (Germany)):de-DE:local", + "Grandpa (English (U.K.)):en-GB:local", + "Grandpa (English (U.S.)):en-US:local", + "Grandpa (Spanish (Spain)):es-ES:local", + "Grandpa (Spanish (Mexico)):es-MX:local", + "Grandpa (Finnish (Finland)):fi-FI:local", + "Grandpa (French (Canada)):fr-CA:local", + "Grandpa (French (France)):fr-FR:local", + "Grandpa (Italian (Italy)):it-IT:local", + "Grandpa (Portuguese (Brazil)):pt-BR:local", + "Jester:en-US:local", + "Ioana:ro-RO:local", + "Jacques:fr-FR:local", + "Joana:pt-PT:local", + "Junior:en-US:local", + "Kanya:th-TH:local", + "Karen:en-AU:local", + "Kathy:en-US:local", + "Kyoko:ja-JP:local", + "Lana:hr-HR:local", + "Laura:sk-SK:local", + "Lekha:hi-IN:local", + "Lesya:uk-UA:local", + "Linh:vi-VN:local", + "Luciana:pt-BR:local", + "Majed:ar-001:local", + "Tünde:hu-HU:local", + "Meijia:zh-TW:local", + "Melina:el-GR:local", + "Milena:ru-RU:local", + "Moira:en-IE:local", + "Mónica:es-ES:local", + "Montse:ca-ES:local", + "Nora:nb-NO:local", + "Organ:en-US:local", + "Paulina:es-MX:local", + "Superstar:en-US:local", + "Ralph:en-US:local", + "Reed (German (Germany)):de-DE:local", + "Reed (English (U.K.)):en-GB:local", + "Reed (English (U.S.)):en-US:local", + "Reed (Spanish (Spain)):es-ES:local", + "Reed (Spanish (Mexico)):es-MX:local", + "Reed (Finnish (Finland)):fi-FI:local", + "Reed (French (Canada)):fr-CA:local", + "Reed (Italian (Italy)):it-IT:local", + "Reed (Portuguese (Brazil)):pt-BR:local", + "Rishi:en-IN:local", + "Rocko (German (Germany)):de-DE:local", + "Rocko (English (U.K.)):en-GB:local", + "Rocko (English (U.S.)):en-US:local", + "Rocko (Spanish (Spain)):es-ES:local", + "Rocko (Spanish (Mexico)):es-MX:local", + "Rocko (Finnish (Finland)):fi-FI:local", + "Rocko (French (Canada)):fr-CA:local", + "Rocko (French (France)):fr-FR:local", + "Rocko (Italian (Italy)):it-IT:local", + "Rocko (Portuguese (Brazil)):pt-BR:local", + "Samantha:en-US:local", + "Sandy (German (Germany)):de-DE:local", + "Sandy (English (U.K.)):en-GB:local", + "Sandy (English (U.S.)):en-US:local", + "Sandy (Spanish (Spain)):es-ES:local", + "Sandy (Spanish (Mexico)):es-MX:local", + "Sandy (Finnish (Finland)):fi-FI:local", + "Sandy (French (Canada)):fr-CA:local", + "Sandy (French (France)):fr-FR:local", + "Sandy (Italian (Italy)):it-IT:local", + "Sandy (Portuguese (Brazil)):pt-BR:local", + "Sara:da-DK:local", + "Satu:fi-FI:local", + "Shelley (German (Germany)):de-DE:local", + "Shelley (English (U.K.)):en-GB:local", + "Shelley (English (U.S.)):en-US:local", + "Shelley (Spanish (Spain)):es-ES:local", + "Shelley (Spanish (Mexico)):es-MX:local", + "Shelley (Finnish (Finland)):fi-FI:local", + "Shelley (French (Canada)):fr-CA:local", + "Shelley (French (France)):fr-FR:local", + "Shelley (Italian (Italy)):it-IT:local", + "Shelley (Portuguese (Brazil)):pt-BR:local", + "Sinji:zh-HK:local", + "Tessa:en-ZA:local", + "Thomas:fr-FR:local", + "Tingting:zh-CN:local", + "Trinoids:en-US:local", + "Whisper:en-US:local", + "Xander:nl-NL:local", + "Yelda:tr-TR:local", + "Yuna:ko-KR:local", + "Zarvox:en-US:local", + "Zosia:pl-PL:local", + "Zuzana:cs-CZ:local" + ] + }, + { + "navigator": { + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:147.0) Gecko/20100101 Firefox/147.0", + "platform": "MacIntel", + "hardwareConcurrency": 4, + "maxTouchPoints": 0 + }, + "screen": { + "width": 1440, + "height": 900, + "colorDepth": 30, + "availWidth": 1440, + "availHeight": 900, + "devicePixelRatio": 2 + }, + "webgl": { + "unmaskedVendor": "Intel Inc.", + "unmaskedRenderer": "Intel(R) HD Graphics, or similar" + }, + "speechVoices": [ + "Alex:en-US:local", + "Alice:it-IT:local", + "Alva:sv-SE:local", + "Amelie:fr-CA:local", + "Anna:de-DE:local", + "Carmit:he-IL:local", + "Damayanti:id-ID:local", + "Daniel:en-GB:local", + "Diego:es-AR:local", + "Ellen:nl-BE:local", + "Fiona:en-scotland:local", + "Fred:en-US:local", + "Ioana:ro-RO:local", + "Joana:pt-PT:local", + "Jorge:es-ES:local", + "Juan:es-MX:local", + "Kanya:th-TH:local", + "Karen:en-AU:local", + "Kyoko:ja-JP:local", + "Laura:sk-SK:local", + "Lekha:hi-IN:local", + "Luca:it-IT:local", + "Luciana:pt-BR:local", + "Maged:ar-SA:local", + "Mariska:hu-HU:local", + "Mei-Jia:zh-TW:local", + "Melina:el-GR:local", + "Milena:ru-RU:local", + "Moira:en-IE:local", + "Monica:es-ES:local", + "Nora:nb-NO:local", + "Paulina:es-MX:local", + "Rishi:en-IN:local", + "Samantha:en-US:local", + "Sara:da-DK:local", + "Satu:fi-FI:local", + "Sin-ji:zh-HK:local", + "Tessa:en-ZA:local", + "Thomas:fr-FR:local", "Ting-Ting:zh-CN:local", "Veena:en-IN:local", "Victoria:en-US:local", @@ -280,17 +774,540 @@ }, { "navigator": { - "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:148.0) Gecko/20100101 Firefox/148.0", + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:148.0) Gecko/20100101 Firefox/148.0", + "platform": "MacIntel", + "hardwareConcurrency": 10, + "maxTouchPoints": 0 + }, + "screen": { + "width": 1280, + "height": 832, + "colorDepth": 30, + "availWidth": 1280, + "availHeight": 746, + "devicePixelRatio": 2 + }, + "webgl": { + "unmaskedVendor": "Apple", + "unmaskedRenderer": "Apple M1, or similar" + } + }, + { + "navigator": { + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:148.0) Gecko/20100101 Firefox/148.0", + "platform": "MacIntel", + "hardwareConcurrency": 8, + "maxTouchPoints": 0 + }, + "screen": { + "width": 1470, + "height": 956, + "colorDepth": 30, + "availWidth": 1470, + "availHeight": 923, + "devicePixelRatio": 2 + }, + "webgl": { + "unmaskedVendor": "Apple", + "unmaskedRenderer": "Apple M1, or similar" + } + }, + { + "navigator": { + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:147.0) Gecko/20100101 Firefox/147.0", + "platform": "MacIntel", + "hardwareConcurrency": 14, + "maxTouchPoints": 0 + }, + "screen": { + "width": 1512, + "height": 982, + "colorDepth": 30, + "availWidth": 1512, + "availHeight": 886, + "devicePixelRatio": 2 + }, + "webgl": { + "unmaskedVendor": "Apple", + "unmaskedRenderer": "Apple M1, or similar" + }, + "speechVoices": [ + "Albert:en-US:local", + "Alice:it-IT:local", + "Alva:sv-SE:local", + "Amélie:fr-CA:local", + "Amira:ms-MY:local", + "Anna:de-DE:local", + "Bad News:en-US:local", + "Bahh:en-US:local", + "Bells:en-US:local", + "Boing:en-US:local", + "Bubbles:en-US:local", + "Carmit:he-IL:local", + "Cellos:en-US:local", + "Damayanti:id-ID:local", + "Daniel:en-GB:local", + "Daria:bg-BG:local", + "Wobble:en-US:local", + "Eddy (German (Germany)):de-DE:local", + "Eddy (English (UK)):en-GB:local", + "Eddy (English (US)):en-US:local", + "Eddy (Spanish (Spain)):es-ES:local", + "Eddy (Spanish (Mexico)):es-MX:local", + "Eddy (Finnish (Finland)):fi-FI:local", + "Eddy (French (Canada)):fr-CA:local", + "Eddy (French (France)):fr-FR:local", + "Eddy (Italian (Italy)):it-IT:local", + "Eddy (Japanese (Japan)):ja-JP:local", + "Eddy (Korean (South Korea)):ko-KR:local", + "Eddy (Portuguese (Brazil)):pt-BR:local", + "Eddy (Chinese (China mainland)):zh-CN:local", + "Eddy (Chinese (Taiwan)):zh-TW:local", + "Ellen:nl-BE:local", + "Flo (German (Germany)):de-DE:local", + "Flo (English (UK)):en-GB:local", + "Flo (English (US)):en-US:local", + "Flo (Spanish (Spain)):es-ES:local", + "Flo (Spanish (Mexico)):es-MX:local", + "Flo (Finnish (Finland)):fi-FI:local", + "Flo (French (Canada)):fr-CA:local", + "Flo (French (France)):fr-FR:local", + "Flo (Italian (Italy)):it-IT:local", + "Flo (Japanese (Japan)):ja-JP:local", + "Flo (Korean (South Korea)):ko-KR:local", + "Flo (Portuguese (Brazil)):pt-BR:local", + "Flo (Chinese (China mainland)):zh-CN:local", + "Flo (Chinese (Taiwan)):zh-TW:local", + "Fred:en-US:local", + "Good News:en-US:local", + "Grandma (German (Germany)):de-DE:local", + "Grandma (English (UK)):en-GB:local", + "Grandma (English (US)):en-US:local", + "Grandma (Spanish (Spain)):es-ES:local", + "Grandma (Spanish (Mexico)):es-MX:local", + "Grandma (Finnish (Finland)):fi-FI:local", + "Grandma (French (Canada)):fr-CA:local", + "Grandma (French (France)):fr-FR:local", + "Grandma (Italian (Italy)):it-IT:local", + "Grandma (Japanese (Japan)):ja-JP:local", + "Grandma (Korean (South Korea)):ko-KR:local", + "Grandma (Portuguese (Brazil)):pt-BR:local", + "Grandma (Chinese (China mainland)):zh-CN:local", + "Grandma (Chinese (Taiwan)):zh-TW:local", + "Grandpa (German (Germany)):de-DE:local", + "Grandpa (English (UK)):en-GB:local", + "Grandpa (English (US)):en-US:local", + "Grandpa (Spanish (Spain)):es-ES:local", + "Grandpa (Spanish (Mexico)):es-MX:local", + "Grandpa (Finnish (Finland)):fi-FI:local", + "Grandpa (French (Canada)):fr-CA:local", + "Grandpa (French (France)):fr-FR:local", + "Grandpa (Italian (Italy)):it-IT:local", + "Grandpa (Japanese (Japan)):ja-JP:local", + "Grandpa (Korean (South Korea)):ko-KR:local", + "Grandpa (Portuguese (Brazil)):pt-BR:local", + "Grandpa (Chinese (China mainland)):zh-CN:local", + "Grandpa (Chinese (Taiwan)):zh-TW:local", + "Jester:en-US:local", + "Ioana:ro-RO:local", + "Jacques:fr-FR:local", + "Joana:pt-PT:local", + "Junior:en-US:local", + "Kanya:th-TH:local", + "Karen:en-AU:local", + "Kathy:en-US:local", + "Kyoko:ja-JP:local", + "Lana:hr-HR:local", + "Laura:sk-SK:local", + "Lekha:hi-IN:local", + "Lesya:uk-UA:local", + "Linh:vi-VN:local", + "Luciana:pt-BR:local", + "Majed:ar-001:local", + "Tünde:hu-HU:local", + "Meijia:zh-TW:local", + "Melina:el-GR:local", + "Milena:ru-RU:local", + "Moira:en-IE:local", + "Mónica:es-ES:local", + "Montse:ca-ES:local", + "Nora:nb-NO:local", + "Organ:en-US:local", + "Paulina:es-MX:local", + "Superstar:en-US:local", + "Ralph:en-US:local", + "Reed (German (Germany)):de-DE:local", + "Reed (English (UK)):en-GB:local", + "Reed (English (US)):en-US:local", + "Reed (Spanish (Spain)):es-ES:local", + "Reed (Spanish (Mexico)):es-MX:local", + "Reed (Finnish (Finland)):fi-FI:local", + "Reed (French (Canada)):fr-CA:local", + "Reed (Italian (Italy)):it-IT:local", + "Reed (Japanese (Japan)):ja-JP:local", + "Reed (Korean (South Korea)):ko-KR:local", + "Reed (Portuguese (Brazil)):pt-BR:local", + "Reed (Chinese (China mainland)):zh-CN:local", + "Reed (Chinese (Taiwan)):zh-TW:local", + "Rishi:en-IN:local", + "Rocko (German (Germany)):de-DE:local", + "Rocko (English (UK)):en-GB:local", + "Rocko (English (US)):en-US:local", + "Rocko (Spanish (Spain)):es-ES:local", + "Rocko (Spanish (Mexico)):es-MX:local", + "Rocko (Finnish (Finland)):fi-FI:local", + "Rocko (French (Canada)):fr-CA:local", + "Rocko (French (France)):fr-FR:local", + "Rocko (Italian (Italy)):it-IT:local", + "Rocko (Japanese (Japan)):ja-JP:local", + "Rocko (Korean (South Korea)):ko-KR:local", + "Rocko (Portuguese (Brazil)):pt-BR:local", + "Rocko (Chinese (China mainland)):zh-CN:local", + "Rocko (Chinese (Taiwan)):zh-TW:local", + "Samantha:en-US:local", + "Sandy (German (Germany)):de-DE:local", + "Sandy (English (UK)):en-GB:local", + "Sandy (English (US)):en-US:local", + "Sandy (Spanish (Spain)):es-ES:local", + "Sandy (Spanish (Mexico)):es-MX:local", + "Sandy (Finnish (Finland)):fi-FI:local", + "Sandy (French (Canada)):fr-CA:local", + "Sandy (French (France)):fr-FR:local", + "Sandy (Italian (Italy)):it-IT:local", + "Sandy (Japanese (Japan)):ja-JP:local", + "Sandy (Korean (South Korea)):ko-KR:local", + "Sandy (Portuguese (Brazil)):pt-BR:local", + "Sandy (Chinese (China mainland)):zh-CN:local", + "Sandy (Chinese (Taiwan)):zh-TW:local", + "Sara:da-DK:local", + "Satu:fi-FI:local", + "Shelley (German (Germany)):de-DE:local", + "Shelley (English (UK)):en-GB:local", + "Shelley (English (US)):en-US:local", + "Shelley (Spanish (Spain)):es-ES:local", + "Shelley (Spanish (Mexico)):es-MX:local", + "Shelley (Finnish (Finland)):fi-FI:local", + "Shelley (French (Canada)):fr-CA:local", + "Shelley (French (France)):fr-FR:local", + "Shelley (Italian (Italy)):it-IT:local", + "Shelley (Japanese (Japan)):ja-JP:local", + "Shelley (Korean (South Korea)):ko-KR:local", + "Shelley (Portuguese (Brazil)):pt-BR:local", + "Shelley (Chinese (China mainland)):zh-CN:local", + "Shelley (Chinese (Taiwan)):zh-TW:local", + "Sinji:zh-HK:local", + "Tessa:en-ZA:local", + "Thomas:fr-FR:local", + "Tina:sl-SI:local", + "Tingting:zh-CN:local", + "Trinoids:en-US:local", + "Vani:ta-IN:local", + "Whisper:en-US:local", + "Xander:nl-NL:local", + "Yelda:tr-TR:local", + "Yuna:ko-KR:local", + "Zarvox:en-US:local", + "Zosia:pl-PL:local", + "Zuzana:cs-CZ:local" + ] + }, + { + "navigator": { + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:148.0) Gecko/20100101 Firefox/148.0", + "platform": "MacIntel", + "hardwareConcurrency": 8, + "maxTouchPoints": 0 + }, + "screen": { + "width": 1920, + "height": 1080, + "colorDepth": 30, + "availWidth": 1920, + "availHeight": 1050 + }, + "webgl": { + "unmaskedVendor": "Apple", + "unmaskedRenderer": "Apple M1, or similar" + }, + "speechVoices": [ + "Albert:en-US:local", + "Alice:it-IT:local", + "Soumya:kn-IN:local", + "Alva:sv-SE:local", + "Aman:en-IN:local", + "Amélie:fr-CA:local", + "Amira:ms-MY:local", + "Anna:de-DE:local", + "Aru:kk-KZ:local", + "Bad News:en-US:local", + "Bahh:en-US:local", + "Bells:en-US:local", + "Boing:en-US:local", + "Bubbles:en-US:local", + "Carmit:he-IL:local", + "Cellos:en-US:local", + "Damayanti:id-ID:local", + "Daniel:en-GB:local", + "Daria:bg-BG:local", + "Wobble:en-US:local", + "Eddy (German (Germany)):de-DE:local", + "Eddy (English (UK)):en-GB:local", + "Eddy (English (US)):en-US:local", + "Eddy (Spanish (Spain)):es-ES:local", + "Eddy (Spanish (Mexico)):es-MX:local", + "Eddy (Finnish (Finland)):fi-FI:local", + "Eddy (French (Canada)):fr-CA:local", + "Eddy (French (France)):fr-FR:local", + "Eddy (Italian (Italy)):it-IT:local", + "Eddy (Japanese (Japan)):ja-JP:local", + "Eddy (Korean (South Korea)):ko-KR:local", + "Eddy (Portuguese (Brazil)):pt-BR:local", + "Eddy (Chinese (China mainland)):zh-CN:local", + "Eddy (Chinese (Taiwan)):zh-TW:local", + "Ellen:nl-BE:local", + "Flo (German (Germany)):de-DE:local", + "Flo (English (UK)):en-GB:local", + "Flo (English (US)):en-US:local", + "Flo (Spanish (Spain)):es-ES:local", + "Flo (Spanish (Mexico)):es-MX:local", + "Flo (Finnish (Finland)):fi-FI:local", + "Flo (French (Canada)):fr-CA:local", + "Flo (French (France)):fr-FR:local", + "Flo (Italian (Italy)):it-IT:local", + "Flo (Japanese (Japan)):ja-JP:local", + "Flo (Korean (South Korea)):ko-KR:local", + "Flo (Portuguese (Brazil)):pt-BR:local", + "Flo (Chinese (China mainland)):zh-CN:local", + "Flo (Chinese (Taiwan)):zh-TW:local", + "Fred:en-US:local", + "Geeta:te-IN:local", + "Good News:en-US:local", + "Grandma (German (Germany)):de-DE:local", + "Grandma (English (UK)):en-GB:local", + "Grandma (English (US)):en-US:local", + "Grandma (Spanish (Spain)):es-ES:local", + "Grandma (Spanish (Mexico)):es-MX:local", + "Grandma (Finnish (Finland)):fi-FI:local", + "Grandma (French (Canada)):fr-CA:local", + "Grandma (French (France)):fr-FR:local", + "Grandma (Italian (Italy)):it-IT:local", + "Grandma (Japanese (Japan)):ja-JP:local", + "Grandma (Korean (South Korea)):ko-KR:local", + "Grandma (Portuguese (Brazil)):pt-BR:local", + "Grandma (Chinese (China mainland)):zh-CN:local", + "Grandma (Chinese (Taiwan)):zh-TW:local", + "Grandpa (German (Germany)):de-DE:local", + "Grandpa (English (UK)):en-GB:local", + "Grandpa (English (US)):en-US:local", + "Grandpa (Spanish (Spain)):es-ES:local", + "Grandpa (Spanish (Mexico)):es-MX:local", + "Grandpa (Finnish (Finland)):fi-FI:local", + "Grandpa (French (Canada)):fr-CA:local", + "Grandpa (French (France)):fr-FR:local", + "Grandpa (Italian (Italy)):it-IT:local", + "Grandpa (Japanese (Japan)):ja-JP:local", + "Grandpa (Korean (South Korea)):ko-KR:local", + "Grandpa (Portuguese (Brazil)):pt-BR:local", + "Grandpa (Chinese (China mainland)):zh-CN:local", + "Grandpa (Chinese (Taiwan)):zh-TW:local", + "Jester:en-US:local", + "Ioana:ro-RO:local", + "Jacques:fr-FR:local", + "Joana:pt-PT:local", + "Junior:en-US:local", + "Kanya:th-TH:local", + "Karen:en-AU:local", + "Kathy:en-US:local", + "Kyoko:ja-JP:local", + "Lana:hr-HR:local", + "Laura:sk-SK:local", + "Lekha:hi-IN:local", + "Lesya:uk-UA:local", + "Linh:vi-VN:local", + "Luciana:pt-BR:local", + "Majed:ar-001:local", + "Tünde:hu-HU:local", + "Meijia:zh-TW:local", + "Melina:el-GR:local", + "Milena:ru-RU:local", + "Moira:en-IE:local", + "Mónica:es-ES:local", + "Montse:ca-ES:local", + "Nora:nb-NO:local", + "Ona:lt-LT:local", + "Organ:en-US:local", + "Paulina:es-MX:local", + "Piya:bn-IN:local", + "Superstar:en-US:local", + "Ralph:en-US:local", + "Reed (German (Germany)):de-DE:local", + "Reed (English (UK)):en-GB:local", + "Reed (English (US)):en-US:local", + "Reed (Spanish (Spain)):es-ES:local", + "Reed (Spanish (Mexico)):es-MX:local", + "Reed (Finnish (Finland)):fi-FI:local", + "Reed (French (Canada)):fr-CA:local", + "Reed (Italian (Italy)):it-IT:local", + "Reed (Japanese (Japan)):ja-JP:local", + "Reed (Korean (South Korea)):ko-KR:local", + "Reed (Portuguese (Brazil)):pt-BR:local", + "Reed (Chinese (China mainland)):zh-CN:local", + "Reed (Chinese (Taiwan)):zh-TW:local", + "Rishi:en-IN:local", + "Rocko (German (Germany)):de-DE:local", + "Rocko (English (UK)):en-GB:local", + "Rocko (English (US)):en-US:local", + "Rocko (Spanish (Spain)):es-ES:local", + "Rocko (Spanish (Mexico)):es-MX:local", + "Rocko (Finnish (Finland)):fi-FI:local", + "Rocko (French (Canada)):fr-CA:local", + "Rocko (French (France)):fr-FR:local", + "Rocko (Italian (Italy)):it-IT:local", + "Rocko (Japanese (Japan)):ja-JP:local", + "Rocko (Korean (South Korea)):ko-KR:local", + "Rocko (Portuguese (Brazil)):pt-BR:local", + "Rocko (Chinese (China mainland)):zh-CN:local", + "Rocko (Chinese (Taiwan)):zh-TW:local", + "Samantha:en-US:local", + "Sandy (German (Germany)):de-DE:local", + "Sandy (English (UK)):en-GB:local", + "Sandy (English (US)):en-US:local", + "Sandy (Spanish (Spain)):es-ES:local", + "Sandy (Spanish (Mexico)):es-MX:local", + "Sandy (Finnish (Finland)):fi-FI:local", + "Sandy (French (Canada)):fr-CA:local", + "Sandy (French (France)):fr-FR:local", + "Sandy (Italian (Italy)):it-IT:local", + "Sandy (Japanese (Japan)):ja-JP:local", + "Sandy (Korean (South Korea)):ko-KR:local", + "Sandy (Portuguese (Brazil)):pt-BR:local", + "Sandy (Chinese (China mainland)):zh-CN:local", + "Sandy (Chinese (Taiwan)):zh-TW:local", + "Sara:da-DK:local", + "Satu:fi-FI:local", + "Shelley (German (Germany)):de-DE:local", + "Shelley (English (UK)):en-GB:local", + "Shelley (English (US)):en-US:local", + "Shelley (Spanish (Spain)):es-ES:local", + "Shelley (Spanish (Mexico)):es-MX:local", + "Shelley (Finnish (Finland)):fi-FI:local", + "Shelley (French (Canada)):fr-CA:local", + "Shelley (French (France)):fr-FR:local", + "Shelley (Italian (Italy)):it-IT:local", + "Shelley (Japanese (Japan)):ja-JP:local", + "Shelley (Korean (South Korea)):ko-KR:local", + "Shelley (Portuguese (Brazil)):pt-BR:local", + "Shelley (Chinese (China mainland)):zh-CN:local", + "Shelley (Chinese (Taiwan)):zh-TW:local", + "Sinji:zh-HK:local", + "Tara:en-IN:local", + "Tessa:en-ZA:local", + "Thomas:fr-FR:local", + "Tina:sl-SI:local", + "Tingting:zh-CN:local", + "Trinoids:en-US:local", + "Vani:ta-IN:local", + "Whisper:en-US:local", + "Xander:nl-NL:local", + "Yelda:tr-TR:local", + "Yuna:ko-KR:local", + "Zarvox:en-US:local", + "Zosia:pl-PL:local", + "Zuzana:cs-CZ:local" + ] + }, + { + "navigator": { + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:148.0) Gecko/20100101 Firefox/148.0", + "platform": "MacIntel", + "hardwareConcurrency": 10, + "maxTouchPoints": 0 + }, + "screen": { + "width": 1470, + "height": 956, + "colorDepth": 30, + "availWidth": 1470, + "availHeight": 923, + "devicePixelRatio": 2 + }, + "webgl": { + "unmaskedVendor": "Apple", + "unmaskedRenderer": "Apple M1, or similar" + } + }, + { + "navigator": { + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:133.0) Gecko/20100101 Firefox/133.0", + "platform": "MacIntel", + "hardwareConcurrency": 8, + "maxTouchPoints": 0 + }, + "screen": { + "width": 2560, + "height": 1440, + "colorDepth": 30, + "availWidth": 2560, + "availHeight": 1440 + }, + "webgl": { + "unmaskedVendor": "ATI Technologies Inc.", + "unmaskedRenderer": "Radeon R9 200 Series, or similar" + } + }, + { + "navigator": { + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:133.0) Gecko/20100101 Firefox/133.0", + "platform": "MacIntel", + "hardwareConcurrency": 8, + "maxTouchPoints": 0 + }, + "screen": { + "width": 1920, + "height": 1080, + "colorDepth": 30, + "availWidth": 1920, + "availHeight": 1055, + "devicePixelRatio": 2 + }, + "webgl": { + "unmaskedVendor": "Apple", + "unmaskedRenderer": "Apple M1, or similar" + } + }, + { + "navigator": { + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/119.0", + "platform": "MacIntel", + "hardwareConcurrency": 2, + "maxTouchPoints": 0 + }, + "screen": { + "width": 1440, + "height": 900, + "colorDepth": 30, + "availWidth": 1440, + "availHeight": 827, + "devicePixelRatio": 2 + }, + "webgl": { + "unmaskedVendor": "Intel Inc.", + "unmaskedRenderer": "Intel(R) HD Graphics 400, or similar" + } + }, + { + "navigator": { + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:133.0) Gecko/20100101 Firefox/133.0", "platform": "MacIntel", - "hardwareConcurrency": 10, + "hardwareConcurrency": 14, "maxTouchPoints": 0 }, "screen": { - "width": 1280, - "height": 832, + "width": 2560, + "height": 1440, "colorDepth": 30, - "availWidth": 1280, - "availHeight": 746, + "availWidth": 2560, + "availHeight": 1355, "devicePixelRatio": 2 }, "webgl": { @@ -300,46 +1317,45 @@ }, { "navigator": { - "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:148.0) Gecko/20100101 Firefox/148.0", + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:133.0) Gecko/20100101 Firefox/133.0", "platform": "MacIntel", "hardwareConcurrency": 8, "maxTouchPoints": 0 }, "screen": { - "width": 1470, - "height": 956, + "width": 1920, + "height": 1080, "colorDepth": 30, - "availWidth": 1470, - "availHeight": 923, - "devicePixelRatio": 2 + "availWidth": 1920, + "availHeight": 987 }, "webgl": { - "unmaskedVendor": "Apple", - "unmaskedRenderer": "Apple M1, or similar" + "unmaskedVendor": "Intel Inc.", + "unmaskedRenderer": "Intel(R) HD Graphics 400, or similar" } }, { "navigator": { - "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:147.0) Gecko/20100101 Firefox/147.0", + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:148.0) Gecko/20100101 Firefox/148.0", "platform": "MacIntel", - "hardwareConcurrency": 14, + "hardwareConcurrency": 2, "maxTouchPoints": 0 }, "screen": { - "width": 1512, - "height": 982, - "colorDepth": 30, - "availWidth": 1512, - "availHeight": 886, - "devicePixelRatio": 2 + "width": 3440, + "height": 1440, + "colorDepth": 24, + "availWidth": 3440, + "availHeight": 1440 }, "webgl": { - "unmaskedVendor": "Apple", - "unmaskedRenderer": "Apple M1, or similar" + "unmaskedVendor": "ATI Technologies Inc.", + "unmaskedRenderer": "Radeon HD 3200 Graphics, or similar" }, "speechVoices": [ "Albert:en-US:local", "Alice:it-IT:local", + "Soumya:kn-IN:local", "Alva:sv-SE:local", "Amélie:fr-CA:local", "Amira:ms-MY:local", @@ -385,6 +1401,7 @@ "Flo (Chinese (China mainland)):zh-CN:local", "Flo (Chinese (Taiwan)):zh-TW:local", "Fred:en-US:local", + "Geeta:te-IN:local", "Good News:en-US:local", "Grandma (German (Germany)):de-DE:local", "Grandma (English (UK)):en-GB:local", @@ -440,6 +1457,7 @@ "Nora:nb-NO:local", "Organ:en-US:local", "Paulina:es-MX:local", + "Piya:bn-IN:local", "Superstar:en-US:local", "Ralph:en-US:local", "Reed (German (Germany)):de-DE:local", @@ -521,15 +1539,16 @@ "navigator": { "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:148.0) Gecko/20100101 Firefox/148.0", "platform": "MacIntel", - "hardwareConcurrency": 8, + "hardwareConcurrency": 16, "maxTouchPoints": 0 }, "screen": { - "width": 1920, - "height": 1080, + "width": 2560, + "height": 1440, "colorDepth": 30, - "availWidth": 1920, - "availHeight": 1050 + "availWidth": 2560, + "availHeight": 1330, + "devicePixelRatio": 2 }, "webgl": { "unmaskedVendor": "Apple", @@ -726,47 +1745,7 @@ "navigator": { "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:148.0) Gecko/20100101 Firefox/148.0", "platform": "MacIntel", - "hardwareConcurrency": 10, - "maxTouchPoints": 0 - }, - "screen": { - "width": 1470, - "height": 956, - "colorDepth": 30, - "availWidth": 1470, - "availHeight": 923, - "devicePixelRatio": 2 - }, - "webgl": { - "unmaskedVendor": "Apple", - "unmaskedRenderer": "Apple M1, or similar" - } - }, - { - "navigator": { - "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:133.0) Gecko/20100101 Firefox/133.0", - "platform": "MacIntel", - "hardwareConcurrency": 8, - "maxTouchPoints": 0 - }, - "screen": { - "width": 1920, - "height": 1080, - "colorDepth": 30, - "availWidth": 1920, - "availHeight": 1055, - "devicePixelRatio": 2 - }, - "webgl": { - "unmaskedVendor": "Apple", - "unmaskedRenderer": "Apple M1, or similar" - } - }, - { - "navigator": { - "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:133.0) Gecko/20100101 Firefox/133.0", - "platform": "MacIntel", - "hardwareConcurrency": 14, + "hardwareConcurrency": 16, "maxTouchPoints": 0 }, "screen": { @@ -774,36 +1753,15 @@ "height": 1440, "colorDepth": 30, "availWidth": 2560, - "availHeight": 1355, - "devicePixelRatio": 2 + "availHeight": 1415 }, "webgl": { "unmaskedVendor": "Apple", "unmaskedRenderer": "Apple M1, or similar" - } - }, - { - "navigator": { - "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:148.0) Gecko/20100101 Firefox/148.0", - "platform": "MacIntel", - "hardwareConcurrency": 2, - "maxTouchPoints": 0 - }, - "screen": { - "width": 3440, - "height": 1440, - "colorDepth": 24, - "availWidth": 3440, - "availHeight": 1440 - }, - "webgl": { - "unmaskedVendor": "ATI Technologies Inc.", - "unmaskedRenderer": "Radeon HD 3200 Graphics, or similar" }, "speechVoices": [ "Albert:en-US:local", "Alice:it-IT:local", - "Soumya:kn-IN:local", "Alva:sv-SE:local", "Amélie:fr-CA:local", "Amira:ms-MY:local", @@ -849,7 +1807,6 @@ "Flo (Chinese (China mainland)):zh-CN:local", "Flo (Chinese (Taiwan)):zh-TW:local", "Fred:en-US:local", - "Geeta:te-IN:local", "Good News:en-US:local", "Grandma (German (Germany)):de-DE:local", "Grandma (English (UK)):en-GB:local", @@ -905,7 +1862,6 @@ "Nora:nb-NO:local", "Organ:en-US:local", "Paulina:es-MX:local", - "Piya:bn-IN:local", "Superstar:en-US:local", "Ralph:en-US:local", "Reed (German (Germany)):de-DE:local", @@ -991,27 +1947,24 @@ "maxTouchPoints": 0 }, "screen": { - "width": 2560, - "height": 1440, + "width": 1680, + "height": 1050, "colorDepth": 30, - "availWidth": 2560, - "availHeight": 1330, + "availWidth": 1680, + "availHeight": 947, "devicePixelRatio": 2 }, "webgl": { - "unmaskedVendor": "Apple", - "unmaskedRenderer": "Apple M1, or similar" + "unmaskedVendor": "Intel Inc.", + "unmaskedRenderer": "Intel(R) HD Graphics 400, or similar" }, "speechVoices": [ "Albert:en-US:local", "Alice:it-IT:local", - "Soumya:kn-IN:local", "Alva:sv-SE:local", - "Aman:en-IN:local", "Amélie:fr-CA:local", "Amira:ms-MY:local", "Anna:de-DE:local", - "Aru:kk-KZ:local", "Bad News:en-US:local", "Bahh:en-US:local", "Bells:en-US:local", @@ -1053,7 +2006,6 @@ "Flo (Chinese (China mainland)):zh-CN:local", "Flo (Chinese (Taiwan)):zh-TW:local", "Fred:en-US:local", - "Geeta:te-IN:local", "Good News:en-US:local", "Grandma (German (Germany)):de-DE:local", "Grandma (English (UK)):en-GB:local", @@ -1107,10 +2059,8 @@ "Mónica:es-ES:local", "Montse:ca-ES:local", "Nora:nb-NO:local", - "Ona:lt-LT:local", "Organ:en-US:local", "Paulina:es-MX:local", - "Piya:bn-IN:local", "Superstar:en-US:local", "Ralph:en-US:local", "Reed (German (Germany)):de-DE:local", @@ -1173,7 +2123,6 @@ "Shelley (Chinese (China mainland)):zh-CN:local", "Shelley (Chinese (Taiwan)):zh-TW:local", "Sinji:zh-HK:local", - "Tara:en-IN:local", "Tessa:en-ZA:local", "Thomas:fr-FR:local", "Tina:sl-SI:local", @@ -1193,15 +2142,86 @@ "navigator": { "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:148.0) Gecko/20100101 Firefox/148.0", "platform": "MacIntel", - "hardwareConcurrency": 16, + "hardwareConcurrency": 2, "maxTouchPoints": 0 }, "screen": { - "width": 2560, - "height": 1440, + "width": 1440, + "height": 900, "colorDepth": 30, - "availWidth": 2560, - "availHeight": 1415 + "availWidth": 1440, + "availHeight": 900, + "devicePixelRatio": 2 + }, + "webgl": { + "unmaskedVendor": "Intel Inc.", + "unmaskedRenderer": "Intel(R) HD Graphics 400, or similar" + }, + "speechVoices": [ + "Alex:en-US:local", + "Alice:it-IT:local", + "Alva:sv-SE:local", + "Amelie:fr-CA:local", + "Anna:de-DE:local", + "Carmit:he-IL:local", + "Damayanti:id-ID:local", + "Daniel:en-GB:local", + "Diego:es-AR:local", + "Ellen:nl-BE:local", + "Fiona:en-scotland:local", + "Fred:en-US:local", + "Ioana:ro-RO:local", + "Joana:pt-PT:local", + "Jorge:es-ES:local", + "Juan:es-MX:local", + "Kanya:th-TH:local", + "Karen:en-AU:local", + "Kyoko:ja-JP:local", + "Laura:sk-SK:local", + "Lekha:hi-IN:local", + "Luca:it-IT:local", + "Luciana:pt-BR:local", + "Maged:ar-SA:local", + "Mariska:hu-HU:local", + "Mei-Jia:zh-TW:local", + "Melina:el-GR:local", + "Milena:ru-RU:local", + "Moira:en-IE:local", + "Monica:es-ES:local", + "Nora:nb-NO:local", + "Paulina:es-MX:local", + "Rishi:en-IN:local", + "Samantha:en-US:local", + "Sara:da-DK:local", + "Satu:fi-FI:local", + "Sin-ji:zh-HK:local", + "Tessa:en-ZA:local", + "Thomas:fr-FR:local", + "Ting-Ting:zh-CN:local", + "Veena:en-IN:local", + "Victoria:en-US:local", + "Xander:nl-NL:local", + "Yelda:tr-TR:local", + "Yuna:ko-KR:local", + "Yuri:ru-RU:local", + "Zosia:pl-PL:local", + "Zuzana:cs-CZ:local" + ] + }, + { + "navigator": { + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:148.0) Gecko/20100101 Firefox/148.0", + "platform": "MacIntel", + "hardwareConcurrency": 8, + "maxTouchPoints": 0 + }, + "screen": { + "width": 1440, + "height": 900, + "colorDepth": 30, + "availWidth": 1440, + "availHeight": 870, + "devicePixelRatio": 2 }, "webgl": { "unmaskedVendor": "Apple", @@ -1210,10 +2230,13 @@ "speechVoices": [ "Albert:en-US:local", "Alice:it-IT:local", + "Soumya:kn-IN:local", "Alva:sv-SE:local", + "Aman:en-IN:local", "Amélie:fr-CA:local", "Amira:ms-MY:local", "Anna:de-DE:local", + "Aru:kk-KZ:local", "Bad News:en-US:local", "Bahh:en-US:local", "Bells:en-US:local", @@ -1255,6 +2278,7 @@ "Flo (Chinese (China mainland)):zh-CN:local", "Flo (Chinese (Taiwan)):zh-TW:local", "Fred:en-US:local", + "Geeta:te-IN:local", "Good News:en-US:local", "Grandma (German (Germany)):de-DE:local", "Grandma (English (UK)):en-GB:local", @@ -1308,8 +2332,10 @@ "Mónica:es-ES:local", "Montse:ca-ES:local", "Nora:nb-NO:local", + "Ona:lt-LT:local", "Organ:en-US:local", "Paulina:es-MX:local", + "Piya:bn-IN:local", "Superstar:en-US:local", "Ralph:en-US:local", "Reed (German (Germany)):de-DE:local", @@ -1372,6 +2398,7 @@ "Shelley (Chinese (China mainland)):zh-CN:local", "Shelley (Chinese (Taiwan)):zh-TW:local", "Sinji:zh-HK:local", + "Tara:en-IN:local", "Tessa:en-ZA:local", "Thomas:fr-FR:local", "Tina:sl-SI:local", @@ -1391,31 +2418,29 @@ "navigator": { "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:148.0) Gecko/20100101 Firefox/148.0", "platform": "MacIntel", - "hardwareConcurrency": 8, + "hardwareConcurrency": 6, "maxTouchPoints": 0 }, "screen": { - "width": 1440, - "height": 900, + "width": 1792, + "height": 1120, "colorDepth": 30, - "availWidth": 1440, - "availHeight": 870, + "availWidth": 1792, + "availHeight": 1090, "devicePixelRatio": 2 }, "webgl": { - "unmaskedVendor": "Apple", - "unmaskedRenderer": "Apple M1, or similar" + "unmaskedVendor": "Intel Inc.", + "unmaskedRenderer": "Intel(R) HD Graphics 400, or similar" }, "speechVoices": [ "Albert:en-US:local", "Alice:it-IT:local", "Soumya:kn-IN:local", "Alva:sv-SE:local", - "Aman:en-IN:local", "Amélie:fr-CA:local", "Amira:ms-MY:local", "Anna:de-DE:local", - "Aru:kk-KZ:local", "Bad News:en-US:local", "Bahh:en-US:local", "Bells:en-US:local", @@ -1511,7 +2536,6 @@ "Mónica:es-ES:local", "Montse:ca-ES:local", "Nora:nb-NO:local", - "Ona:lt-LT:local", "Organ:en-US:local", "Paulina:es-MX:local", "Piya:bn-IN:local", @@ -1577,7 +2601,6 @@ "Shelley (Chinese (China mainland)):zh-CN:local", "Shelley (Chinese (Taiwan)):zh-TW:local", "Sinji:zh-HK:local", - "Tara:en-IN:local", "Tessa:en-ZA:local", "Thomas:fr-FR:local", "Tina:sl-SI:local", diff --git a/pythonlib/camoufox/fingerprints.py b/pythonlib/camoufox/fingerprints.py index ae6fb92ac..cb18defab 100644 --- a/pythonlib/camoufox/fingerprints.py +++ b/pythonlib/camoufox/fingerprints.py @@ -1685,7 +1685,8 @@ def generate_context_fingerprint( # display to reconcile against, so the floor is unconditional. raise_screen_to_modern_floor(config) webgl_fp = sample_webgl_for_screen( - _target_os, config.get('screen.width'), config.get('screen.height') + _target_os, config.get('screen.width'), config.get('screen.height'), + cores=config.get('navigator.hardwareConcurrency'), ) webgl_fp.pop('webGl2Enabled') config.update(webgl_fp) diff --git a/pythonlib/camoufox/utils.py b/pythonlib/camoufox/utils.py index 485aef9af..c0889da74 100644 --- a/pythonlib/camoufox/utils.py +++ b/pythonlib/camoufox/utils.py @@ -1074,9 +1074,6 @@ def launch_options( target_os = get_target_os(config) - # Drop values the source supplied that this identity cannot keep, before the - # pools below defer to them (a preset's own GPU pair wins over sampling). - coherence.drop_incoherent_source_values(config, target_os) # A preset whose screen is a phone viewport is not a real desktop device; # the floor is normally skipped for presets, on the assumption that a preset # IS a real machine, which 736x414 disproves. @@ -1421,6 +1418,12 @@ def launch_options( LeakWarning.warn('disable_coop', i_know_what_im_doing) firefox_user_prefs['browser.tabs.remote.useCrossOriginOpenerPolicy'] = False + # Drop values the source supplied that this identity cannot keep, before the + # WebGL pool below defers to them (a preset's own GPU pair wins over + # sampling). Here, not earlier, because the check reads the core count and + # the screen, which the host core count and the display clamp replace. + coherence.drop_incoherent_source_values(config, target_os) + if block_webgl: firefox_user_prefs['webgl.disabled'] = True LeakWarning.warn('block_webgl', i_know_what_im_doing) @@ -1443,6 +1446,7 @@ def launch_options( webgl_fp = sample_webgl_for_screen( target_os, config.get('screen.width'), config.get('screen.height'), seed=identity_seed(config, _identity_salt), + cores=config.get('navigator.hardwareConcurrency'), ) enable_webgl2 = webgl_fp.pop('webGl2Enabled') diff --git a/pythonlib/camoufox/webgl.py b/pythonlib/camoufox/webgl.py index 08fde48d5..6fa94d823 100644 --- a/pythonlib/camoufox/webgl.py +++ b/pythonlib/camoufox/webgl.py @@ -16,7 +16,7 @@ import orjson -from .coherence import gpu_fits_os +from .coherence import gpu_fits_machine, gpu_fits_os from .fingerprints import _FPGEN_OS, gpu_screen_is_plausible, is_software_renderer from .fpgen_model import load_fpgen @@ -163,13 +163,16 @@ def sample_webgl_for_screen( width: Optional[int] = None, height: Optional[int] = None, seed: Optional[int] = None, + cores: Optional[int] = None, ) -> Dict[str, Any]: """Draw a GPU for a synthetic identity, weighted as fpgen records Firefox on `target_os`, and its WebGL config. Only GPUs the rest of the identity can stand beside are drawn: never a software rasteriser, a GPU the OS cannot report, the resistFingerprinting - mask, or a discrete GPU behind a netbook panel (see gpu_screen_is_plausible). + mask, a discrete GPU behind a netbook panel (see gpu_screen_is_plausible), + or an Intel Mac GPU beside cores or a panel no Intel Mac has + (coherence.gpu_fits_machine). The screen is left alone: it has already been reconciled with the real display and the window (#499). @@ -183,6 +186,7 @@ def sample_webgl_for_screen( if not is_software_renderer(result.value['renderer']) and result.value['renderer'] != _RFP_RENDERER and gpu_fits_os(result.value['renderer'], target_os) + and gpu_fits_machine(result.value['renderer'], target_os, cores, width, height) and gpu_screen_is_plausible(result.value['renderer'], width, height) ) if not candidates: diff --git a/pythonlib/tests/test_coherence.py b/pythonlib/tests/test_coherence.py index db4d07f88..0d68a814f 100644 --- a/pythonlib/tests/test_coherence.py +++ b/pythonlib/tests/test_coherence.py @@ -6,12 +6,18 @@ cannot prevent it. These tests run the assembled identity, from every source, past camoufox.coherence. -Measured before the layer existed (2026-09-17): 14% of macOS identities drew -"Radeon R9 200 Series", a desktop PC card; 7% paired Apple Silicon with -colorDepth 24; 2% of Windows identities reported maxTouchPoints 256; and 18 of -the 312 bundled presets carried a GPU or a screen no desktop has. +Measured before the layer existed (2026-09-17): 7% of macOS identities paired +Apple Silicon with colorDepth 24; 2% of Windows identities reported +maxTouchPoints 256; and bundled presets carried screens no desktop has. + +"Intel(R) HD Graphics 400" and "Radeon R9 200 Series" on macOS are not in that +list: they are the buckets Firefox reports for an Intel Mac's UHD 630 and a +Radeon Pro 5300M (its own TestCiMac and TestMacAmd cases). What an Intel Mac +cannot have is a notched Apple Silicon panel, or a core count no Intel Mac with +that GPU reports. """ +import re import sys from os.path import dirname, join @@ -22,10 +28,47 @@ from camoufox import coherence # noqa: E402 from camoufox import fingerprints as fp # noqa: E402 from camoufox.utils import get_target_os # noqa: E402 +from camoufox.webgl import sample_webgl_for_screen # noqa: E402 from test_identity_salt import launch # noqa: E402 +def _firefox_bucket(raw): + """The AMD and Intel branches of Firefox's SanitizeRenderer for a macOS + GL_RENDERER (dom/canvas/SanitizeRenderer.cpp, FIREFOX_152_0_4_RELEASE).""" + device = re.sub(r" OpenGL Engine$", "", raw) + if any(part in device for part in ("REMBRANDT", "RENOIR", "Vega", "VII", "Fury")): + return "Radeon R9 200 Series, or similar" + radeon = re.search(r"Radeon.*?((R[579X]|HD) )?([0-9][0-9][0-9]+)", device) + if radeon: + if radeon.group(2) == "HD": + model = int(radeon.group(3)) + return ("Radeon HD 5850" if model >= 5000 else "Radeon HD 3200 Graphics") + ", or similar" + return "Radeon R9 200 Series, or similar" + intel = re.search(r"Intel.*Graphics( P?([0-9][0-9][0-9]+))?", device) + if intel: + if not intel.group(1): + return "Intel(R) HD Graphics, or similar" + model = int(intel.group(2)) + return ("Intel(R) HD Graphics" if 1000 <= model < 5000 else "Intel(R) HD Graphics 400") + ", or similar" + raise ValueError(f"not an AMD or Intel renderer: {raw!r}") + + +# What Intel Macs' GPUs report through Firefox. An Intel model number under +# 1000 or of 5000 and over lands in "HD Graphics 400"; a Radeon without an "HD" +# prefix lands in "R9 200 Series". +MAC_RENDERERS = [ + ("Intel(R) UHD Graphics 630", "Intel(R) HD Graphics 400, or similar"), # Mac mini 2018, 15"/16" MacBook Pro + ("Intel(R) Iris(TM) Plus Graphics 655", "Intel(R) HD Graphics 400, or similar"), # 13" MacBook Pro 2018-19 + ("Intel HD Graphics 6000", "Intel(R) HD Graphics 400, or similar"), # MacBook Air 2015-17 + ("Intel(R) Iris(TM) Plus Graphics", "Intel(R) HD Graphics, or similar"), # MacBook Air 2020 + ("Intel HD Graphics 4000", "Intel(R) HD Graphics, or similar"), # MacBook Air / Mac mini 2012 + ("AMD Radeon Pro 5300M OpenGL Engine", "Radeon R9 200 Series, or similar"), # 16" MacBook Pro + ("AMD Radeon Pro 560X OpenGL Engine", "Radeon R9 200 Series, or similar"), # 15" MacBook Pro 2018 + ("AMD Radeon Pro Vega 56 OpenGL Engine", "Radeon R9 200 Series, or similar"), # iMac Pro +] + + class TestRules: """Each rule, against the value that motivated it.""" @@ -35,16 +78,61 @@ def test_apple_silicon_never_has_fewer_than_eight_cores(self): assert coherence.apply(config, "mac") == [] assert config["navigator.hardwareConcurrency"] == 8 - def test_a_mac_cannot_report_a_braswell_atom_igp(self): - # The retired WebGL database weighted this at 7.4% of the macOS pool, - # and fpgen records it from macOS too. - config = {"webGl:renderer": "Intel(R) HD Graphics 400, or similar"} - assert [v.rule for v in coherence.validate(config, "mac")] == ["gpu-matches-os"] + def test_the_bucket_port_agrees_with_firefoxs_own_mac_cases(self): + # dom/canvas/gtest/TestSanitizeRenderer.cpp, FIREFOX_152_0_4_RELEASE. + assert _firefox_bucket("AMD Radeon Pro 5300M OpenGL Engine") == "Radeon R9 200 Series, or similar" + assert _firefox_bucket("Intel(R) UHD Graphics 630") == "Intel(R) HD Graphics 400, or similar" + + @pytest.mark.parametrize("raw, bucket", MAC_RENDERERS) + def test_an_intel_mac_reports_its_firefox_bucket(self, raw, bucket): + assert _firefox_bucket(raw) == bucket + assert coherence.gpu_fits_os(bucket, "mac"), raw def test_a_mac_cannot_report_an_angle_renderer(self): + # Firefox 152 renders WebGL on macOS through CGL, and its sanitizer has + # no ANGLE-on-Metal branch (Bug 2046027 added one after 152). config = {"webGl:renderer": "ANGLE (Intel, Intel(R) HD Graphics Direct3D11 vs_5_0), or similar"} assert [v.rule for v in coherence.validate(config, "mac")] == ["gpu-matches-os"] + def test_a_mac_cannot_report_a_software_rasteriser(self): + assert not coherence.gpu_fits_os("llvmpipe, or similar", "mac") + + def test_an_intel_igp_mac_reports_its_physical_or_logical_cores(self): + igp = "Intel(R) HD Graphics 400, or similar" + for cores in (2, 4, 6, 8, 12, 16): + assert coherence.validate({"webGl:renderer": igp, "navigator.hardwareConcurrency": cores}, "mac") == [], cores + # No Mac that runs WebGL on its Intel IGP has more than 8 cores. + for cores in (10, 14, 20, 24, 32): + config = {"webGl:renderer": igp, "navigator.hardwareConcurrency": cores} + assert [v.rule for v in coherence.validate(config, "mac")] == ["intel-mac-hardware"], cores + + def test_a_discrete_gpu_mac_reaches_the_mac_pro(self): + amd = "Radeon R9 200 Series, or similar" + for cores in (2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 24, 28, 32, 48, 56): + assert coherence.validate({"webGl:renderer": amd, "navigator.hardwareConcurrency": cores}, "mac") == [], cores + config = {"webGl:renderer": amd, "navigator.hardwareConcurrency": 22} + assert [v.rule for v in coherence.validate(config, "mac")] == ["intel-mac-hardware"] + + @pytest.mark.parametrize("renderer", [ + "Intel(R) HD Graphics 400, or similar", + "Intel(R) HD Graphics, or similar", + "Radeon R9 200 Series, or similar", + "Radeon HD 3200 Graphics, or similar", + ]) + def test_an_intel_mac_has_no_notched_panel(self, renderer): + for width, height in ((1470, 956), (1512, 982), (1728, 1117), (2056, 1329), (1710, 1107)): + config = {"webGl:renderer": renderer, "screen.width": width, "screen.height": height} + assert [v.rule for v in coherence.validate(config, "mac")] == ["intel-mac-hardware"], (width, height) + # The same panels are what Apple Silicon MacBooks report. + assert coherence.validate({"webGl:renderer": "Apple M1, or similar", "screen.width": 1470, + "screen.height": 956}, "mac") == [] + + def test_a_preset_gpu_the_final_identity_contradicts_is_dropped(self): + config = {"webGl:vendor": "Intel Inc.", "webGl:renderer": "Intel(R) HD Graphics 400, or similar", + "navigator.hardwareConcurrency": 20} + assert [v.rule for v in coherence.drop_incoherent_source_values(config, "mac")] == ["intel-mac-hardware"] + assert "webGl:renderer" not in config and "webGl:vendor" not in config + def test_windows_renders_through_angle(self): assert coherence.gpu_fits_os("ANGLE (NVIDIA, NVIDIA GeForce GTX 980 Direct3D11), or similar", "win") assert not coherence.gpu_fits_os("Apple M1, or similar", "win") @@ -131,11 +219,53 @@ def test_the_real_machines_pass(self): ("mac", {"navigator.platform": "MacIntel", "navigator.hardwareConcurrency": 10, "navigator.maxTouchPoints": 0, "screen.width": 2560, "screen.height": 1440, "screen.colorDepth": 30, "webGl:renderer": "Apple M1, or similar"}), + # fpgen model-2/2026's Radeon R9 200 macOS record: a 16" MacBook Pro + # (6 physical cores) at its default scaled resolution. + ("mac", {"navigator.platform": "MacIntel", "navigator.hardwareConcurrency": 6, + "navigator.maxTouchPoints": 0, "screen.width": 1792, "screen.height": 1120, + "screen.colorDepth": 30, "webGl:renderer": "Radeon R9 200 Series, or similar"}), ] for target_os, config in real: assert coherence.validate(config, target_os) == [], target_os +INTEL_MAC_BUCKETS = {"Intel(R) HD Graphics 400, or similar", "Radeon R9 200 Series, or similar"} + + +class TestIntelMacDraws: + """The draw offers Intel Macs, but only beside a core count and a screen an + Intel Mac could have.""" + + def test_every_intel_mac_bucket_is_drawn(self): + # fpgen model-2/2026 records each at 1.1% of Firefox on macOS. + drawn = {sample_webgl_for_screen("mac", 2560, 1440, seed=s)["webGl:renderer"] for s in range(300)} + assert INTEL_MAC_BUCKETS <= drawn + + def test_no_intel_mac_behind_a_notched_panel(self): + for seed in range(300): + renderer = sample_webgl_for_screen("mac", 1470, 956, seed=seed, cores=8)["webGl:renderer"] + assert "Apple M" in renderer, (seed, renderer) + + def test_no_intel_igp_beside_a_core_count_no_intel_mac_reports(self): + drawn = {sample_webgl_for_screen("mac", 2560, 1440, seed=s, cores=20)["webGl:renderer"] for s in range(300)} + assert not any("Intel" in renderer for renderer in drawn), drawn + + def test_a_preset_gpu_is_checked_against_the_host_core_count(self, monkeypatch): + """A launch replaces the preset's core count with the host's, after the + preset's GPU has been read: the GPU has to fit the count that ships.""" + preset = dict(fp.load_presets("150")["presets"]["macos"][0]) + preset["navigator"] = {**preset["navigator"], "hardwareConcurrency": 8} + preset["screen"] = {**preset["screen"], "width": 1440, "height": 900, "availWidth": 1440, + "availHeight": 875} + preset["webgl"] = {**preset["webgl"], "unmaskedVendor": "Intel Inc.", + "unmaskedRenderer": "Intel(R) HD Graphics 400, or similar"} + monkeypatch.setattr(fp, "host_cpu_count", lambda: 20) + config = launch(os="macos", fingerprint_preset=preset) + assert config["navigator.hardwareConcurrency"] == 20 + assert "Intel" not in config["webGl:renderer"] + assert coherence.validate(config, "mac") == [] + + class TestEveryIdentityIsCoherent: """The assembled identity, from each source Camoufox draws one from.""" diff --git a/pythonlib/tests/test_shipped_data.py b/pythonlib/tests/test_shipped_data.py index 5ee580692..bd21dc8f9 100644 --- a/pythonlib/tests/test_shipped_data.py +++ b/pythonlib/tests/test_shipped_data.py @@ -9,7 +9,9 @@ Dropped on 2026-09-17: 38 of 435 presets (26 with a GPU their OS cannot report, 7 pairing Apple Silicon with a core count Apple never shipped, 4 with a colour depth their GPU contradicts, 3 with a phone viewport, 1 claiming 40 touch -points). +points). 24 of them, macOS presets naming "Intel(R) HD Graphics 400" or "Radeon +R9 200 Series", are restored: those are the buckets Firefox reports for an +Intel Mac's GPU (see camoufox.coherence). """ import json diff --git a/typescript/scripts/golden/identity_golden.py b/typescript/scripts/golden/identity_golden.py index 00cdc8a30..d7e613b74 100644 --- a/typescript/scripts/golden/identity_golden.py +++ b/typescript/scripts/golden/identity_golden.py @@ -343,6 +343,15 @@ def record_webgl(): for_screen.append({'os': os_key, 'w': 1920, 'h': 1080, 'seed': str(seed), 'renderer': out['webGl:renderer'], 'hash': exact(out)}) + # The core count and a notched panel narrow the macOS draw. + for_machine = [] + for cores in (8, 20, 22): + for w, hh in ((2560, 1440), (1470, 956)): + for seed in range(20): + out = webgl.sample_webgl_for_screen('mac', w, hh, seed=seed, cores=cores) + for_machine.append({'os': 'mac', 'w': w, 'h': hh, 'seed': str(seed), 'cores': cores, + 'renderer': out['webGl:renderer'], 'hash': exact(out)}) + # Every GPU fpgen records for each OS, and every bundled preset's GPU. for_gpu = [] targets = {(os_key, v, r) for os_key in OS_KEYS for v, r in gpus[os_key]} @@ -375,8 +384,8 @@ def record_webgl(): converted = [{'os': os_key, 'webgl2': w2, 'hash': exact(webgl.to_config(recorded, w2, os_key))} for os_key in OS_KEYS for w2 in (recorded2, [])] - write('webgl.json', {'gpus': gpus, 'forScreen': for_screen, 'forGpu': for_gpu, 'errors': errors, - 'recorded': recorded, 'recorded2': recorded2, 'converted': converted}) + write('webgl.json', {'gpus': gpus, 'forScreen': for_screen, 'forMachine': for_machine, 'forGpu': for_gpu, + 'errors': errors, 'recorded': recorded, 'recorded2': recorded2, 'converted': converted}) # --------------------------------------------------------------------------- @@ -513,6 +522,10 @@ def record_coherence(): renderers = fpgen_renderers() + EXTRA_RENDERERS rng = random.Random(9001) fits = [[r, os_key, coherence.gpu_fits_os(r, os_key)] for r in renderers + [None] for os_key in OS_KEYS + ('bsd',)] + machine = [[r, os_key, cores, w, hh, coherence.gpu_fits_machine(r, os_key, cores, w, hh)] + for r in renderers + [None] for os_key in OS_KEYS + ('bsd',) + for cores in (None, 2, 8, 10, 20, 22, 56, True, '8') + for w, hh in ((1470, 956), (2056, 1329), (1920, 1080), ('1470', 956), (None, None))] gpu = [] for r in renderers + [None]: for w, hh in ((1024, 600), (800, 480), (1024, 768), (1366, 768), (1920, 1080), (None, None), (0, 600)): @@ -537,6 +550,8 @@ def record_coherence(): for key, value in fields.items(): if rng.random() < 0.6: c[key] = value + if rng.random() < 0.2: + c['screen.width'], c['screen.height'] = rng.choice(sorted(coherence.APPLE_SILICON_PANELS)) target = rng.choice(OS_KEYS + ('bsd',)) v = [list(x) for x in coherence.validate(c, target)] applied = copy.deepcopy(c) @@ -550,7 +565,7 @@ def record_coherence(): if i < 40: case.update(validateFull=v, appliedFull=applied) cases.append(case) - write('coherence.json', {'fits': fits, 'gpuScreen': gpu, 'cases': cases}) + write('coherence.json', {'fits': fits, 'machine': machine, 'gpuScreen': gpu, 'cases': cases}) # --------------------------------------------------------------------------- @@ -678,6 +693,9 @@ def record_constants(): 'plausibleCoreCounts': list(fp.PLAUSIBLE_CORE_COUNTS), 'modernScreenFloor': list(fp.MODERN_SCREEN_FLOOR), 'appleSiliconCores': sorted(coherence.APPLE_SILICON_CORES), + 'intelMacIgpCores': sorted(coherence.INTEL_MAC_IGP_CORES), + 'intelMacDgpuCores': sorted(coherence.INTEL_MAC_DGPU_CORES), + 'appleSiliconPanels': sorted(f'{w}x{hh}' for w, hh in coherence.APPLE_SILICON_PANELS), 'plausibleDpr': {k: list(v) for k, v in coherence.PLAUSIBLE_DPR.items()}, 'plausibleColorDepth': sorted(coherence.PLAUSIBLE_COLOR_DEPTH), 'maxTouchPoints': coherence.MAX_PLAUSIBLE_TOUCH_POINTS, diff --git a/typescript/src/coherence.ts b/typescript/src/coherence.ts index b8aab43fd..7ccd3f3b5 100644 --- a/typescript/src/coherence.ts +++ b/typescript/src/coherence.ts @@ -38,13 +38,30 @@ export const MAX_PLAUSIBLE_TOUCH_POINTS = 10; /** The browser's own chrome height, in CSS pixels (a property of the binary). */ export const BROWSER_CHROME_HEIGHT = 86; -/** GPU strings that are not possible on macOS. */ -const NOT_A_MAC_GPU = [ - "ANGLE", - "Intel(R) HD Graphics 400", - "Radeon R9 200 Series", - "llvmpipe", -]; +/** + * GPU strings that are not possible on macOS: Firefox 152 has no ANGLE on a + * Mac. "Intel(R) HD Graphics 400" and "Radeon R9 200 Series" are the buckets + * an Intel Mac's UHD 630 and Radeon Pro 5300M report. + */ +const NOT_A_MAC_GPU = ["ANGLE", "llvmpipe"]; + +/** navigator.hardwareConcurrency on an Intel Mac, physical or logical. */ +export const INTEL_MAC_IGP_CORES: ReadonlySet = new Set([ + 2, 4, 6, 8, 12, 16, +]); +/** A discrete GPU adds the desktops to those, up to the 2019 Mac Pro. */ +export const INTEL_MAC_DGPU_CORES: ReadonlySet = new Set([ + 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 24, 28, 32, 36, 48, 56, +]); + +/** Screens only Apple Silicon Macs have: the notched MacBooks, the 24" iMac. */ +export const APPLE_SILICON_PANELS: ReadonlySet = new Set([ + ...["1024x665", "1280x832", "1470x956", "1710x1112"], // Air 13.6" + ...["1280x828", "1440x932", "1710x1107"], // Air 15.3" + ...["1147x745", "1352x878", "1512x982", "1800x1169"], // Pro 14" + ...["1312x848", "1496x967", "1728x1117", "2056x1329"], // Pro 16" + "2240x1260", // iMac 24" +]); export interface Violation { rule: string; @@ -109,6 +126,67 @@ export function gpuFitsOs( return true; } +function isAppleSiliconPanel(width: unknown, height: unknown): boolean { + const w = num(width); + const h = num(height); + return ( + typeof w === "number" && + typeof h === "number" && + APPLE_SILICON_PANELS.has(`${w}x${h}`) + ); +} + +/** Why no Intel Mac reports this GPU beside these cores and this screen. */ +function intelMacMisfit( + rendererString: unknown, + cores: unknown, + width: unknown, + height: unknown, +): string | null { + const r = pyTruthy(rendererString) ? pyStr(rendererString) : ""; + if (!r || r.includes("Apple M")) return null; + const allowed = r.includes("Intel") + ? INTEL_MAC_IGP_CORES + : INTEL_MAC_DGPU_CORES; + if (isPyInt(cores) && !allowed.has(Number(cores))) { + return `${pyRepr(r)} with hardwareConcurrency ${pyStr(cores)}; no Intel Mac with that GPU reports it`; + } + if (isAppleSiliconPanel(width, height)) { + return `${pyRepr(r)} behind a ${pyStr(width)}x${pyStr(height)} panel, which only Apple Silicon Macs have`; + } + return null; +} + +/** + * Whether a machine with this GPU has these cores and this screen. Only Intel + * Macs are modelled; serves the WebGL draw and the finished-identity check. + */ +export function gpuFitsMachine( + rendererString: string | null | undefined, + targetOs: string, + cores?: unknown, + width?: unknown, + height?: unknown, +): boolean { + return ( + targetOs !== "mac" || + intelMacMisfit(rendererString, cores, width, height) === null + ); +} + +function checkIntelMacHardware( + config: Config, + targetOs: string, +): string | null { + if (targetOs !== "mac") return null; + return intelMacMisfit( + renderer(config), + config["navigator.hardwareConcurrency"], + config["screen.width"], + config["screen.height"], + ); +} + function checkGpuMatchesOs(config: Config, targetOs: string): string | null { const r = renderer(config); if (!r || gpuFitsOs(r, targetOs)) return null; @@ -294,6 +372,7 @@ export const RULES: readonly Rule[] = [ repair: repairAppleSiliconCores, }, { name: "gpu-matches-os", check: checkGpuMatchesOs, repair: null }, + { name: "intel-mac-hardware", check: checkIntelMacHardware, repair: null }, { name: "color-depth", check: checkColorDepth, repair: repairColorDepth }, { name: "touch-points", check: checkTouchPoints, repair: repairTouchPoints }, { @@ -338,24 +417,23 @@ export function repairScreenOrientation(config: Config): boolean { /** * Discard values a source supplied that this identity cannot keep (a preset's - * GPU pair its OS cannot report), so the normal WebGL sampling draws a - * coherent one instead. Only values another pool can replace are dropped. + * GPU pair its OS, or its final core count and screen, rule out), so the + * normal WebGL sampling draws a coherent one instead. Run after the core count + * and the screen are final. Only values another pool can replace are dropped. */ export function dropIncoherentSourceValues( config: Config, targetOs: string, ): Violation[] { - const dropped: Violation[] = []; const r = renderer(config); - if (r && !gpuFitsOs(r, targetOs)) { - delete config["webGl:renderer"]; - delete config["webGl:vendor"]; - dropped.push({ - rule: "gpu-matches-os", - detail: `dropped ${pyRepr(r)} for a ${targetOs} identity`, - }); - } - return dropped; + if (!r) return []; + let rule: string; + if (!gpuFitsOs(r, targetOs)) rule = "gpu-matches-os"; + else if (checkIntelMacHardware(config, targetOs)) rule = "intel-mac-hardware"; + else return []; + delete config["webGl:renderer"]; + delete config["webGl:vendor"]; + return [{ rule, detail: `dropped ${pyRepr(r)} for a ${targetOs} identity` }]; } /** Every invariant this identity breaks. Empty means coherent. */ diff --git a/typescript/src/fingerprints.ts b/typescript/src/fingerprints.ts index 75b6dd9c1..a2fb4a09b 100644 --- a/typescript/src/fingerprints.ts +++ b/typescript/src/fingerprints.ts @@ -1803,6 +1803,8 @@ export function generateContextFingerprint({ targetOs, config["screen.width"], config["screen.height"], + undefined, + config["navigator.hardwareConcurrency"], ); delete webglFp.webGl2Enabled; Object.assign(config, webglFp); diff --git a/typescript/src/utils.ts b/typescript/src/utils.ts index f12248006..443b7136f 100644 --- a/typescript/src/utils.ts +++ b/typescript/src/utils.ts @@ -1382,9 +1382,6 @@ export async function launchOptions({ const targetOs = getTargetOs(config); - // Drop values the source supplied that this identity cannot keep, before the - // pools below defer to them (a preset's own GPU pair wins over sampling). - coherence.dropIncoherentSourceValues(config, targetOs); // A preset whose screen is a phone viewport is not a real desktop device. if (!userSetScreenWindow && coherence.screenIsImplausible(config)) { coherence.repairScreenOrientation(config); @@ -1683,6 +1680,11 @@ export async function launchOptions({ false; } + // Drop values the source supplied that this identity cannot keep, before the + // WebGL pool below defers to them. Here, not earlier, because the check + // reads the core count and the screen, which the launch replaces above. + coherence.dropIncoherentSourceValues(config, targetOs); + if (block_webgl) { firefox_user_prefs["webgl.disabled"] = true; LeakWarning.warn("block_webgl", i_know_what_im_doing); @@ -1715,6 +1717,7 @@ export async function launchOptions({ config["screen.width"], config["screen.height"], seed(), + config["navigator.hardwareConcurrency"], ); } const enableWebgl2 = webglFp.webGl2Enabled; diff --git a/typescript/src/webgl.ts b/typescript/src/webgl.ts index 76b160076..d95e42282 100644 --- a/typescript/src/webgl.ts +++ b/typescript/src/webgl.ts @@ -12,7 +12,7 @@ * then webgl2), so a seeded identity presents the same device in both * launchers. */ -import { gpuFitsOs } from "./coherence.js"; +import { gpuFitsMachine, gpuFitsOs } from "./coherence.js"; import { FPGEN_OS, gpuScreenIsPlausible, @@ -233,7 +233,8 @@ export function webglForGpu( * * Only GPUs the rest of the identity can stand beside are drawn: never a * software rasteriser, a GPU the OS cannot report, the resistFingerprinting - * mask, or a discrete GPU behind a netbook panel. The screen is left alone: + * mask, a discrete GPU behind a netbook panel, or an Intel Mac GPU beside + * cores or a panel no Intel Mac has. The screen is left alone: * it has already been reconciled with the real display and the window (#499). * * @throws ValueError when no recorded GPU fits. @@ -243,12 +244,14 @@ export function sampleWebglForScreen( width?: number | null, height?: number | null, seed?: PySeed, + cores?: number | null, ): WebGLData { const candidates = trace("gpu", targetOs).filter( (result) => !isSoftwareRenderer(result.value.renderer) && result.value.renderer !== RFP_RENDERER && gpuFitsOs(result.value.renderer, targetOs) && + gpuFitsMachine(result.value.renderer, targetOs, cores, width, height) && gpuScreenIsPlausible(result.value.renderer, width, height), ); if (!candidates.length) { diff --git a/typescript/tests/coherence.test.ts b/typescript/tests/coherence.test.ts index e971af87b..5e6e4f03c 100644 --- a/typescript/tests/coherence.test.ts +++ b/typescript/tests/coherence.test.ts @@ -29,13 +29,80 @@ describe("rules", () => { expect(config["navigator.hardwareConcurrency"]).toBe(8); }); - it("a Mac cannot report a Braswell Atom IGP", () => { + it("an Intel Mac reports its Firefox bucket", () => { + // SanitizeRenderer's buckets for a UHD 630 and a Radeon Pro 5300M + // (TestSanitizeRenderer.cpp TestCiMac, TestMacAmd; Firefox 152). + for (const bucket of [ + "Intel(R) HD Graphics 400, or similar", + "Radeon R9 200 Series, or similar", + ]) { + expect(coherence.gpuFitsOs(bucket, "mac"), bucket).toBe(true); + } + expect(coherence.gpuFitsOs("llvmpipe, or similar", "mac")).toBe(false); + }); + + it("an Intel IGP Mac reports its physical or logical cores", () => { + const igp = "Intel(R) HD Graphics 400, or similar"; + const at = (cores: number) => + rules( + { "webGl:renderer": igp, "navigator.hardwareConcurrency": cores }, + "mac", + ); + for (const cores of [2, 4, 6, 8, 12, 16]) { + expect(at(cores), String(cores)).toEqual([]); + } + for (const cores of [10, 14, 20, 24, 32]) { + expect(at(cores), String(cores)).toEqual(["intel-mac-hardware"]); + } + }); + + it("a discrete GPU Mac reaches the Mac Pro", () => { + const amd = "Radeon R9 200 Series, or similar"; + const at = (cores: number) => + rules( + { "webGl:renderer": amd, "navigator.hardwareConcurrency": cores }, + "mac", + ); + for (const cores of [ + 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 24, 28, 32, 48, 56, + ]) { + expect(at(cores), String(cores)).toEqual([]); + } + expect(at(22)).toEqual(["intel-mac-hardware"]); + }); + + it.each([ + "Intel(R) HD Graphics 400, or similar", + "Intel(R) HD Graphics, or similar", + "Radeon R9 200 Series, or similar", + "Radeon HD 3200 Graphics, or similar", + ])("an Intel Mac (%s) has no notched panel", (renderer) => { + for (const [w, h] of [ + [1470, 956], + [1512, 982], + [1728, 1117], + [2056, 1329], + [1710, 1107], + ]) { + expect( + rules( + { "webGl:renderer": renderer, "screen.width": w, "screen.height": h }, + "mac", + ), + `${w}x${h}`, + ).toEqual(["intel-mac-hardware"]); + } + // The same panels are what Apple Silicon MacBooks report. expect( rules( - { "webGl:renderer": "Intel(R) HD Graphics 400, or similar" }, + { + "webGl:renderer": "Apple M1, or similar", + "screen.width": 1470, + "screen.height": 956, + }, "mac", ), - ).toEqual(["gpu-matches-os"]); + ).toEqual([]); }); it("a Mac cannot report an ANGLE renderer", () => { @@ -208,6 +275,20 @@ describe("rules", () => { "webGl:renderer": "Apple M1, or similar", }, ], + // fpgen model-2/2026's Radeon R9 200 macOS record: a 16" MacBook + // Pro (6 physical cores) at its default scaled resolution. + [ + "mac", + { + "navigator.platform": "MacIntel", + "navigator.hardwareConcurrency": 6, + "navigator.maxTouchPoints": 0, + "screen.width": 1792, + "screen.height": 1120, + "screen.colorDepth": 30, + "webGl:renderer": "Radeon R9 200 Series, or similar", + }, + ], ]; for (const [os, config] of real) { expect(coherence.validate(config, os), os).toEqual([]); @@ -216,14 +297,26 @@ describe("rules", () => { it("drops a source GPU the OS cannot report, and only that", () => { const config: Record = { - "webGl:vendor": "Intel", - "webGl:renderer": "Intel(R) HD Graphics 400, or similar", + "webGl:vendor": "Google Inc. (Intel)", + "webGl:renderer": + "ANGLE (Intel, Intel(R) HD Graphics Direct3D11 vs_5_0 ps_5_0), or similar", "screen.width": 1920, }; const dropped = coherence.dropIncoherentSourceValues(config, "mac"); expect(dropped.map((v) => v.rule)).toEqual(["gpu-matches-os"]); expect(config).toEqual({ "screen.width": 1920 }); }); + + it("drops a source GPU the final core count rules out", () => { + const config: Record = { + "webGl:vendor": "Intel Inc.", + "webGl:renderer": "Intel(R) HD Graphics 400, or similar", + "navigator.hardwareConcurrency": 20, + }; + const dropped = coherence.dropIncoherentSourceValues(config, "mac"); + expect(dropped.map((v) => v.rule)).toEqual(["intel-mac-hardware"]); + expect(config).toEqual({ "navigator.hardwareConcurrency": 20 }); + }); }); describe("shipped data (test_shipped_data.py)", () => { diff --git a/typescript/tests/identity-golden.test.ts b/typescript/tests/identity-golden.test.ts index a1e026bc6..be54780c8 100644 --- a/typescript/tests/identity-golden.test.ts +++ b/typescript/tests/identity-golden.test.ts @@ -103,6 +103,15 @@ describe("constants", () => { expect([...coherence.APPLE_SILICON_CORES].sort((a, b) => a - b)).toEqual( c.appleSiliconCores, ); + expect([...coherence.INTEL_MAC_IGP_CORES].sort((a, b) => a - b)).toEqual( + c.intelMacIgpCores, + ); + expect([...coherence.INTEL_MAC_DGPU_CORES].sort((a, b) => a - b)).toEqual( + c.intelMacDgpuCores, + ); + expect([...coherence.APPLE_SILICON_PANELS].sort()).toEqual( + c.appleSiliconPanels, + ); // Iteration order matters: the dpr repair breaks ties by it. expect(coherence.PLAUSIBLE_DPR).toEqual(c.plausibleDpr); expect([...coherence.PLAUSIBLE_COLOR_DEPTH].sort()).toEqual( @@ -376,6 +385,20 @@ describe("webgl", () => { expect(exact(out), label).toBe(c.hash); } }); + it(`reproduces ${fx.forMachine.length} draws narrowed by the core count`, () => { + for (const c of fx.forMachine) { + const out = webgl.sampleWebglForScreen( + c.os, + c.w, + c.h, + big(c.seed), + c.cores, + ); + const label = `${c.os} ${c.w}x${c.h} cores ${c.cores} seed ${c.seed}`; + expect(out["webGl:renderer"], label).toBe(c.renderer); + expect(exact(out), label).toBe(c.hash); + } + }); it(`reproduces ${fx.forGpu.length} draws for a named GPU`, () => { for (const c of fx.forGpu) { let got: any; @@ -490,10 +513,16 @@ describe("geometry fixes", () => { describe("coherence", () => { const fx = load("coherence.json.gz"); - it("gpuFitsOs / gpuScreenIsPlausible / rendererBucket", () => { + it("gpuFitsOs / gpuFitsMachine / gpuScreenIsPlausible / rendererBucket", () => { for (const [r, osKey, fits] of fx.fits) { expect(coherence.gpuFitsOs(r, osKey), `${r} ${osKey}`).toBe(fits); } + for (const [r, osKey, cores, w, hh, fits] of fx.machine) { + expect( + coherence.gpuFitsMachine(r, osKey, cores, w, hh), + `${r} ${osKey} ${cores} ${w}x${hh}`, + ).toBe(fits); + } for (const [r, w, hh, plausible, software, bucket] of fx.gpuScreen) { expect(fp.gpuScreenIsPlausible(r, w, hh), `${r} ${w}x${hh}`).toBe( plausible, diff --git a/typescript/tests/launch.test.ts b/typescript/tests/launch.test.ts index 864f8aa6c..7fa7bdbb3 100644 --- a/typescript/tests/launch.test.ts +++ b/typescript/tests/launch.test.ts @@ -805,6 +805,36 @@ describe.skipIf(!modelReady)( ]); }); + it("a preset GPU is checked against the host core count (test_coherence.py)", async () => { + // The launch replaces the preset's cores with the host's after the + // preset's GPU is read: the GPU has to fit the count that ships. + const base = fingerprints.loadPresets("152")?.presets.macos[0]; + const preset = { + ...base, + navigator: { ...base.navigator, hardwareConcurrency: 8 }, + screen: { + ...base.screen, + width: 1440, + height: 900, + availWidth: 1440, + availHeight: 875, + }, + webgl: { + ...base.webgl, + unmaskedVendor: "Intel Inc.", + unmaskedRenderer: "Intel(R) HD Graphics 400, or similar", + }, + }; + deps.fixHardwareConcurrency = (config, canPin) => + fingerprints.fixHardwareConcurrency(config, canPin, { cpuCount: 20 }); + const config = await launchConfig({ + os: "macos", + fingerprint_preset: preset, + }); + expect(config["navigator.hardwareConcurrency"]).toBe(20); + expect(config["webGl:renderer"]).not.toContain("Intel"); + }); + it("a preset GPU fpgen has never seen raises", async () => { const preset = { ...fingerprints.loadPresets("152")?.presets.windows[0], diff --git a/typescript/tests/webgl.test.ts b/typescript/tests/webgl.test.ts index 563c15d4f..f317619cd 100644 --- a/typescript/tests/webgl.test.ts +++ b/typescript/tests/webgl.test.ts @@ -122,6 +122,35 @@ describe.skipIf(!MODEL.ok)("webgl (test_webgl.py)", () => { expect(renderers.size).toBeGreaterThanOrEqual(2); }); + it("every Intel Mac bucket is drawn", () => { + // fpgen model-2/2026 records each at 1.1% of Firefox on macOS. + const drawn = new Set( + SEEDS.map( + (s) => sampleWebglForScreen("mac", 2560, 1440, s)["webGl:renderer"], + ), + ); + expect(drawn).toContain("Intel(R) HD Graphics 400, or similar"); + expect(drawn).toContain("Radeon R9 200 Series, or similar"); + }); + + it("no Intel Mac behind a notched panel", () => { + for (const seed of SEEDS) { + const renderer = sampleWebglForScreen("mac", 1470, 956, seed, 8)[ + "webGl:renderer" + ]; + expect(renderer, `seed ${seed}`).toContain("Apple M"); + } + }); + + it("no Intel IGP beside a core count no Intel Mac reports", () => { + for (const seed of SEEDS) { + const renderer = sampleWebglForScreen("mac", 2560, 1440, seed, 20)[ + "webGl:renderer" + ]; + expect(renderer, `seed ${seed}`).not.toContain("Intel"); + } + }); + it.each(OSES)("a %s netbook screen never draws a discrete GPU", (os) => { for (const seed of SEEDS) { const renderer = sampleWebglForScreen(os, 1024, 600, seed)[ From 1da15be2d630f820d3041d1e399a5b576c2c39d0 Mon Sep 17 00:00:00 2001 From: coffeegrind123 <103264516+coffeegrind123@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:46:37 +0300 Subject: [PATCH 06/12] fix(patches): keep worker reads of per-context values off libpref RoverfoxStorageManager::ReadValue fell through its local cache to Preferences::GetCString on whatever thread asked, and WorkerNavigator asks from worker threads. libpref's table is main-thread only and a release build looks it up without a lock, so a worker reading navigator while the main thread inserted another context's prefs crashed the content process. Off the main thread the local cache is now the whole answer. The main thread keeps it a mirror of every roverfox.s.* pref: a snapshot the first time the process uses the storage there, then the prefix callback that already cleared the miss cache. tests/patches/worker-config-reads.py creates contexts while four workers read navigator. On v152.0.4-beta.30 it crashed the content process in 21 of 21 runs; without workers the same loop held for 60 s. Co-Authored-By: Claude Opus 5.5 (1M context) (cherry picked from commit 338fe9f338a67a5da8e2c4564ccc9ae1b967cc01) --- patches/anti-font-fingerprinting.patch | 76 ++++++++--- tests/patches/worker-config-reads.py | 176 +++++++++++++++++++++++++ 2 files changed, 236 insertions(+), 16 deletions(-) create mode 100644 tests/patches/worker-config-reads.py diff --git a/patches/anti-font-fingerprinting.patch b/patches/anti-font-fingerprinting.patch index d7de58fa5..971cac9c1 100644 --- a/patches/anti-font-fingerprinting.patch +++ b/patches/anti-font-fingerprinting.patch @@ -3,9 +3,10 @@ new file mode 100644 index 0000000000..dcb359d25a --- /dev/null +++ b/dom/base/RoverfoxStorageManager.cpp -@@ -0,0 +1,194 @@ +@@ -0,0 +1,236 @@ +#include "RoverfoxStorageManager.h" +#include "mozilla/Preferences.h" ++#include "nsIPrefBranch.h" +#include "nsXULAppAPI.h" +#include "mozilla/dom/ContentChild.h" +#include "nsThreadUtils.h" @@ -46,17 +47,60 @@ index 0000000000..dcb359d25a +} + +static void OnStoragePrefChanged(const char* aPrefName, void*) { -+ RoverfoxStorageManager::ForgetMiss(nsDependentCString(aPrefName)); ++ RoverfoxStorageManager::MirrorPref(nsDependentCString(aPrefName)); +} + -+/* static */ void RoverfoxStorageManager::ForgetMiss(const nsACString& aPrefName) { ++/* static */ void RoverfoxStorageManager::MirrorPref(const nsACString& aPrefName) { ++ MOZ_ASSERT(NS_IsMainThread()); ++ nsAutoCString value; ++ Preferences::GetCString(PromiseFlatCString(aPrefName).get(), value, kKind); + MutexAutoLock lock(GetLocalCacheMutex()); + GetMissCache().Remove(aPrefName); ++ if (value.IsEmpty()) { ++ GetLocalCache().Remove(aPrefName); ++ } else { ++ GetLocalCache().InsertOrUpdate(aPrefName, value); ++ } ++} ++ ++// libpref's table is main-thread only: a release build looks it up without a ++// lock, so a worker reading it while the main thread inserted a pref (another ++// context putting its values) crashed the content process -- measured within ++// seconds with workers reading navigator while contexts were created. Workers ++// read the local cache alone, and the main thread keeps that cache a mirror of ++// every roverfox.s.* pref: a snapshot the first time this process uses the ++// storage on the main thread, then the prefix callback. A value already cached ++// locally wins over the snapshot, as it does in ReadValue. ++/* static */ void RoverfoxStorageManager::EnsureMirror() { ++ static bool sMirrored = false; ++ if (sMirrored || !NS_IsMainThread()) { ++ return; ++ } ++ sMirrored = true; ++ Preferences::RegisterPrefixCallback(OnStoragePrefChanged, "roverfox.s."_ns); ++ ++ nsTArray names; ++ nsIPrefBranch* root = Preferences::GetRootBranch(kKind); ++ if (!root || NS_FAILED(root->GetChildList("roverfox.s.", names))) { ++ return; ++ } ++ for (const nsCString& name : names) { ++ nsAutoCString value; ++ Preferences::GetCString(name.get(), value, kKind); ++ if (value.IsEmpty()) { ++ continue; ++ } ++ MutexAutoLock lock(GetLocalCacheMutex()); ++ if (!GetLocalCache().Contains(name)) { ++ GetLocalCache().InsertOrUpdate(name, nsCString(value)); ++ } ++ } +} + +/* static */ void +RoverfoxStorageManager::SetPrefValue(const nsCString& prefName, + const nsCString& value) { ++ EnsureMirror(); + // Always store locally for immediate reads in this process + { + MutexAutoLock lock(GetLocalCacheMutex()); @@ -76,18 +120,21 @@ index 0000000000..dcb359d25a + +/* static */ void RoverfoxStorageManager::ReadValue(const nsCString& prefName, + nsACString& outValue) { -+ // Local cache first (values set in this process) ++ EnsureMirror(); ++ // Local cache first (values set in this process, and the pref mirror) + { + MutexAutoLock lock(GetLocalCacheMutex()); + if (auto entry = GetLocalCache().Lookup(prefName)) { + outValue = entry.Data(); + } + } -+ // Then Preferences (values synced from other processes) -+ if (outValue.IsEmpty()) { -+ Preferences::GetCString(prefName.get(), outValue, kKind); ++ // Off the main thread the mirror is the whole answer (see EnsureMirror). ++ if (!outValue.IsEmpty() || !NS_IsMainThread()) { ++ return; + } -+ if (!outValue.IsEmpty() || XRE_IsParentProcess() || !NS_IsMainThread()) { ++ // Then Preferences (values synced from other processes) ++ Preferences::GetCString(prefName.get(), outValue, kKind); ++ if (!outValue.IsEmpty() || XRE_IsParentProcess()) { + return; + } + // Sync IPC read from the parent covers a value another process put that has @@ -102,11 +149,6 @@ index 0000000000..dcb359d25a + return; + } + } -+ static bool sObserving = false; -+ if (!sObserving) { -+ sObserving = true; -+ Preferences::RegisterPrefixCallback(OnStoragePrefChanged, "roverfox.s."_ns); -+ } + ContentChild* cc = ContentChild::GetSingleton(); + if (!cc) { + return; @@ -203,7 +245,7 @@ new file mode 100644 index 0000000000..e6d64378e1 --- /dev/null +++ b/dom/base/RoverfoxStorageManager.h -@@ -0,0 +1,41 @@ +@@ -0,0 +1,43 @@ +#ifndef mozilla_dom_RoverfoxStorageManager_h +#define mozilla_dom_RoverfoxStorageManager_h + @@ -229,13 +271,15 @@ index 0000000000..e6d64378e1 + static void PutString(const nsAString& key, const nsAString& value); + static bool GetString(const nsAString& key, nsAString& outValue); + static void Remove(const nsAString& key); -+ // A put reached this process's prefs: the key is no longer a known miss. -+ static void ForgetMiss(const nsACString& aPrefName); ++ // A put reached this process's prefs: copy it into the local cache (main ++ // thread only), which is all a worker thread reads. ++ static void MirrorPref(const nsACString& aPrefName); + + private: + static nsCString MakePrefName(const nsAString& key); + static void SetPrefValue(const nsCString& prefName, const nsCString& value); + static void ReadValue(const nsCString& prefName, nsACString& outValue); ++ static void EnsureMirror(); + static nsTHashSet& GetMissCache(); + static nsTHashMap& GetLocalCache(); + static mozilla::Mutex& GetLocalCacheMutex(); diff --git a/tests/patches/worker-config-reads.py b/tests/patches/worker-config-reads.py new file mode 100644 index 000000000..8fd1758c2 --- /dev/null +++ b/tests/patches/worker-config-reads.py @@ -0,0 +1,176 @@ +""" +Verify worker reads of per-context values (anti-font-fingerprinting.patch, +RoverfoxStorageManager). + + values a worker in a macOS context of a Windows launch reports the context's + navigator.platform, hardwareConcurrency and timezone, like its window + race workers reading navigator in a tight loop while other contexts are + created (each one's values reach every process as new prefs) do not + crash the content process + +Workers used to fall through to libpref, whose table is main-thread only and +looked up without a lock in a release build. On v152.0.4-beta.30, which reads +it, the race case crashed the content process in 21 of 21 runs, after 0-46 +contexts; with no workers the same loop held for 60 s (67-80 contexts). + + python tests/patches/worker-config-reads.py +""" + +import functools +import http.server +import sys +import tempfile +import threading +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from helpers import resolve_binary # noqa: E402 + +# Four workers for 60 s: two for 30 s crashed beta.30 in only 7 of 11 runs. +RACE_SECONDS = 60 +WORKERS = 4 +# One content process, so every context's prefs land in the workers' process. +ONE_PROCESS = {"dom.ipc.processCount": 1, "dom.ipc.processCount.webIsolated": 1} + +VALUES = """async () => { + const read = () => ({platform: navigator.platform, cores: navigator.hardwareConcurrency, + tz: Intl.DateTimeFormat().resolvedOptions().timeZone}); + const code = 'postMessage((' + read.toString() + ')())'; + const w = new Worker(URL.createObjectURL(new Blob([code]))); + const worker = await new Promise(r => { w.onmessage = e => r(e.data); }); + return {window: read(), worker}; +}""" + +# Workers report their count at most every 100 ms. Posting per fixed batch of +# reads flooded the page's main thread once reads got fast (~16k messages/s +# at ~1e9 reads per 30 s), and on a 4-core runner navigation starved and +# timed out -- the harness failing, not the browser. +SPIN = """(n) => { + window.__reads = 0; + const code = 'let c = 0, last = performance.now(); for(;;){ for(let i=0;i<2000;i++){' + + ' navigator.hardwareConcurrency; navigator.platform; navigator.userAgent; navigator.language; }' + + ' c += 2000; const now = performance.now();' + + ' if (now - last >= 100) { postMessage(c); c = 0; last = now; } }'; + for (let i = 0; i < n; i++) { + const w = new Worker(URL.createObjectURL(new Blob([code]))); + w.onmessage = (e) => { window.__reads += e.data; }; + } +}""" + + +class Quiet(http.server.SimpleHTTPRequestHandler): + def log_message(self, *a): + pass + + def log_error(self, *a): + pass + + +class QuietServer(http.server.ThreadingHTTPServer): + # A page torn down mid-response (a crashed or closed context) breaks the + # pipe; the default handler prints a traceback, which the guard runner + # keeps as the tail of the output in place of the actual verdict. + def handle_error(self, request, client_address): + pass + + +def check_values(binary, url, failures): + from camoufox.sync_api import Camoufox + from camoufox.fingerprints import generate_context_fingerprint + + fp = generate_context_fingerprint(os="macos", timezone="Asia/Tokyo") + with Camoufox(os="windows", headless=True, executable_path=str(binary), + i_know_what_im_doing=True) as browser: + ctx = browser.new_context(**fp["context_options"]) + ctx.add_init_script(fp["init_script"]) + page = ctx.new_page() + page.goto(url) + out = page.evaluate(VALUES) + print(f"values: {out}") + if out["window"]["platform"] != "MacIntel" or out["window"]["tz"] != "Asia/Tokyo": + failures.append(f"values: the context's own values did not apply to its window -- check is vacuous: {out}") + elif out["worker"] != out["window"]: + failures.append(f"values: worker {out['worker']} differs from window {out['window']}") + + +def check_race(binary, url, failures): + from camoufox import DefaultAddons + from camoufox.sync_api import Camoufox + from camoufox.fingerprints import generate_context_fingerprint + + fps = [generate_context_fingerprint(os=o) for o in ("macos", "linux", "windows")] + crashed = [] + errors = [] + created = 0 + gotos = [] + reads = 0 + # Without uBlock Origin, which is reinstalled for every window (#185): on + # beta.30 the fourth context's navigation hung for 45 s in 3 of 3 runs even + # with no workers, a harness error that would hide the race. + with Camoufox(os="windows", headless=True, executable_path=str(binary), + firefox_user_prefs=ONE_PROCESS, exclude_addons=[DefaultAddons.UBO], + i_know_what_im_doing=True) as browser: + spin = browser.new_page() + spin.on("crash", lambda _: crashed.append("spinning page")) + spin.goto(url) + spin.evaluate(SPIN, WORKERS) + deadline = time.time() + RACE_SECONDS + try: + while time.time() < deadline and not crashed: + fp = fps[created % len(fps)] + ctx = browser.new_context(**fp["context_options"]) + ctx.add_init_script(fp["init_script"]) + page = ctx.new_page() + page.on("crash", lambda _: crashed.append("new context's page")) + started = time.monotonic() + page.goto(url, timeout=45000) + gotos.append(time.monotonic() - started) + ctx.close() + created += 1 + if not crashed: + reads = spin.evaluate("window.__reads") + except Exception as exc: + # A crash mid-call surfaces as a closed target; anything else (a + # timeout) is the harness, not the race, and is reported as such. + first = f"{type(exc).__name__}: {str(exc).splitlines()[0]}" + (crashed if "closed" in first.lower() or "crash" in first.lower() else errors).append(first) + # A starved machine slows every navigation; a stall shows as fast ones + # followed by one that never finishes. + slowest = f"{max(gotos):.2f}" if gotos else "-" + print(f"race: {created} contexts in {RACE_SECONDS} s (slowest goto {slowest} s), {reads} worker reads," + f" crashed: {crashed or 'no'}" + (f", harness errors: {errors}" if errors else "")) + if crashed: + failures.append(f"race: content process crashed after {created} contexts ({crashed[0]})") + elif errors: + failures.append(f"race: harness error after {created} contexts ({errors[0]}) -- check is vacuous") + elif created < 10 or reads == 0: + failures.append(f"race: {created} contexts / {reads} worker reads -- check is vacuous") + + +def main() -> int: + binary = resolve_binary() + site = tempfile.mkdtemp() + Path(site, "p.html").write_text("x") + srv = QuietServer(("127.0.0.1", 0), functools.partial(Quiet, directory=site)) + threading.Thread(target=srv.serve_forever, daemon=True).start() + url = f"http://127.0.0.1:{srv.server_address[1]}/p.html" + + failures = [] + try: + check_values(binary, url, failures) + check_race(binary, url, failures) + finally: + srv.shutdown() + + print() + if failures: + for f in failures: + print(f"FAIL: {f}") + return 1 + print("PASS: workers read their context's values and survive concurrent context creation.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 8823d399b81920c011cc2db14ef7edc8653ae260 Mon Sep 17 00:00:00 2001 From: Jake Writer Date: Sun, 27 Sep 2026 11:55:21 -0600 Subject: [PATCH 07/12] fix(fpgen): keep a values.dat the TypeScript launcher decompressed from the pin CAMOUFOX_FPGEN_DATA may point the TS launcher at pythonlib's fpgen data/, and it decompresses values.dat there. ensure_fpgen_model() refused to run whenever values.dat existed, so sharing the directory broke every Python launch. The pin now carries values.dat's sha256 (all three twins): a matching values.dat is kept, any other is removed, since fpgen reads it in preference to the verified archive. The files `fpgen decompress` leaves still fail loudly. Co-Authored-By: Claude Opus 5.5 --- pythonlib/camoufox/fpgen-model.json | 3 +++ pythonlib/camoufox/fpgen_model.py | 20 +++++++++++++++++++- pythonlib/tests/test_fpgen_model.py | 28 ++++++++++++++++++++++++++++ scripts/data/fpgen-model.json | 3 +++ typescript/src/fpgen/pin.ts | 10 ++++++++++ 5 files changed, 63 insertions(+), 1 deletion(-) diff --git a/pythonlib/camoufox/fpgen-model.json b/pythonlib/camoufox/fpgen-model.json index 7016ee117..d0ce1e5f2 100644 --- a/pythonlib/camoufox/fpgen-model.json +++ b/pythonlib/camoufox/fpgen-model.json @@ -15,5 +15,8 @@ "values.dat.zst": "3da2cf0891a4a85ef6458f0fbdf9346acfcd04e5fd279a0ea22d7919e4eaf122", "values.json.zst": "294decde5b6a1a52ed53d50a894130fa5c58f7cc804b78198f5c33f55b3ba66f" }, + "decompressed_sha256": { + "values.dat": "9ce7d16630e91654e73988f7d37e8c99987c09b9c779a7bb9dc7ad6355dcc2f0" + }, "note": "fpgen's model data, pinned. fpgen fetches this itself on first import with TLS verification DISABLED, no checksum, and a release selection that cannot reach this tag -- see pythonlib/camoufox/fpgen_model.py. Bump by editing this file and its twins, pythonlib/camoufox/fpgen-model.json and typescript/src/fpgen/pin.ts (tests fail if they disagree); the sha256 values are the gate." } diff --git a/pythonlib/camoufox/fpgen_model.py b/pythonlib/camoufox/fpgen_model.py index 28d39b880..4f98fda89 100644 --- a/pythonlib/camoufox/fpgen_model.py +++ b/pythonlib/camoufox/fpgen_model.py @@ -39,6 +39,9 @@ # `python -m fpgen decompress` replaces the archive's files with these, which # fpgen then reads in preference to them. DECOMPRESSED_FILES = ('fingerprint-network.json', 'values.json', 'values.dat') +# The TypeScript launcher decompresses values.dat beside the archive's files, +# and CAMOUFOX_FPGEN_DATA may point it at this directory. The pin carries its +# hash, so a values.dat from the pinned model is kept and any other is dropped. # 2100-01-01T00:00:00Z. fpgen refetches files whose mtime is older than this # window (pkgman.files_are_recent). @@ -90,7 +93,11 @@ def ensure_fpgen_model(data_dir: Optional[Path] = None, force: bool = False) -> return data_dir = data_dir or fpgen_data_dir() with _LOCK: - if any((data_dir / name).exists() for name in DECOMPRESSED_FILES): + if any( + (data_dir / name).exists() + for name in DECOMPRESSED_FILES + if name not in PIN['decompressed_sha256'] + ): raise FpgenModelError( f"fpgen's model in {data_dir} is decompressed, so it cannot be checked against " f"the pinned {PIN['tag']}. Remove it with `python -m fpgen remove`; Camoufox " @@ -99,6 +106,7 @@ def ensure_fpgen_model(data_dir: Optional[Path] = None, force: bool = False) -> try: if force or not is_pinned(data_dir): _install(data_dir) + _drop_foreign_decompressed(data_dir) _stamp(data_dir) except PermissionError as e: raise FpgenModelError( @@ -138,6 +146,16 @@ def _install(data_dir: Path) -> None: shutil.rmtree(staging, ignore_errors=True) +def _drop_foreign_decompressed(data_dir: Path) -> None: + """Remove a decompressed file that is not the pinned model's: fpgen would + read it in preference to the verified archive. Hashing values.dat (~210 MB) + takes ~0.2 s, once per process, and only when the file is there.""" + for name, digest in PIN['decompressed_sha256'].items(): + path = data_dir / name + if path.exists() and _hash(path) != digest: + path.unlink(missing_ok=True) + + def _stamp(data_dir: Path) -> None: """Date the files past fpgen's refresh and write the stamp. Files seeded by another user (a root-built image) may be left as they are while fpgen still diff --git a/pythonlib/tests/test_fpgen_model.py b/pythonlib/tests/test_fpgen_model.py index 04614caf5..08b140f57 100644 --- a/pythonlib/tests/test_fpgen_model.py +++ b/pythonlib/tests/test_fpgen_model.py @@ -31,6 +31,8 @@ "values.json.zst": b"values-json" * 1000, "values.dat.zst": b"values-dat" * 1000, } +# What the synthetic values.dat.zst decompresses to, as far as the pin says. +VALUES_DAT = b"decompressed-values" * 1000 STALE = time.time() - FPGEN_MAX_AGE_S - 86400 @@ -57,6 +59,7 @@ def pinned(monkeypatch): "sha256": _sha256(archive), "files": sorted(MEMBERS), "file_sha256": {name: _sha256(data) for name, data in MEMBERS.items()}, + "decompressed_sha256": {"values.dat": _sha256(VALUES_DAT)}, } monkeypatch.setattr(fpgen_model, "PIN", pin) monkeypatch.delenv("FPGEN_MODEL_URL", raising=False) @@ -217,6 +220,31 @@ def test_decompressed_model_fails_loudly(tmp_path, pinned): assert pinned == [] +def test_a_pinned_values_dat_is_shared_with_the_typescript_launcher(tmp_path, pinned): + """CAMOUFOX_FPGEN_DATA may point the TS launcher at this directory, and it + decompresses values.dat there. The pinned model's values.dat stays.""" + data_dir = tmp_path / "data" + ensure_fpgen_model(data_dir) + _write(data_dir, {"values.dat": VALUES_DAT}) + + ensure_fpgen_model(data_dir) + + assert (data_dir / "values.dat").read_bytes() == VALUES_DAT + assert pinned == [fpgen_model.PIN["url"]] + + +def test_a_values_dat_from_another_model_is_dropped(tmp_path, pinned): + """fpgen reads values.dat in preference to values.dat.zst, so one left over + from another model would pair its values with the pinned network.""" + data_dir = tmp_path / "data" + ensure_fpgen_model(data_dir) + _write(data_dir, {"values.dat": b"another model"}) + + ensure_fpgen_model(data_dir) + + assert not (data_dir / "values.dat").exists() + + def test_custom_model_url_is_left_to_fpgen(tmp_path, pinned, monkeypatch): monkeypatch.setenv("FPGEN_MODEL_URL", "https://example.invalid/model.zip") data_dir = tmp_path / "data" diff --git a/scripts/data/fpgen-model.json b/scripts/data/fpgen-model.json index 7016ee117..d0ce1e5f2 100644 --- a/scripts/data/fpgen-model.json +++ b/scripts/data/fpgen-model.json @@ -15,5 +15,8 @@ "values.dat.zst": "3da2cf0891a4a85ef6458f0fbdf9346acfcd04e5fd279a0ea22d7919e4eaf122", "values.json.zst": "294decde5b6a1a52ed53d50a894130fa5c58f7cc804b78198f5c33f55b3ba66f" }, + "decompressed_sha256": { + "values.dat": "9ce7d16630e91654e73988f7d37e8c99987c09b9c779a7bb9dc7ad6355dcc2f0" + }, "note": "fpgen's model data, pinned. fpgen fetches this itself on first import with TLS verification DISABLED, no checksum, and a release selection that cannot reach this tag -- see pythonlib/camoufox/fpgen_model.py. Bump by editing this file and its twins, pythonlib/camoufox/fpgen-model.json and typescript/src/fpgen/pin.ts (tests fail if they disagree); the sha256 values are the gate." } diff --git a/typescript/src/fpgen/pin.ts b/typescript/src/fpgen/pin.ts index 8e2ff973b..567bc548e 100644 --- a/typescript/src/fpgen/pin.ts +++ b/typescript/src/fpgen/pin.ts @@ -21,6 +21,12 @@ export interface ModelPin { readonly files: readonly string[]; /** Each file's sha256, for checking an installed model without its archive. */ readonly file_sha256: Readonly>; + /** + * sha256 of each file the model decompresses to. values.dat is shared with + * pythonlib when CAMOUFOX_FPGEN_DATA points at its fpgen `data/`, and this + * is how pythonlib tells one decompressed from the pinned model. + */ + readonly decompressed_sha256: Readonly>; } export const MODEL_PIN: ModelPin = { @@ -39,4 +45,8 @@ export const MODEL_PIN: ModelPin = { "values.json.zst": "294decde5b6a1a52ed53d50a894130fa5c58f7cc804b78198f5c33f55b3ba66f", }, + decompressed_sha256: { + "values.dat": + "9ce7d16630e91654e73988f7d37e8c99987c09b9c779a7bb9dc7ad6355dcc2f0", + }, }; From 85c2ec1bd879c1021c23494bffc85693b29ec7ad Mon Sep 17 00:00:00 2001 From: coffeegrind123 <103264516+coffeegrind123@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:13:21 +0300 Subject: [PATCH 08/12] fix(juggler): close a crashed page's tab so its window is freed When a content process crashes, Juggler disposes the page's target, which drops it from its browser context. Nothing closed the tab after that: BrowserContext.destroy() only closes the pages it still tracks, and the default context closes none. Every crashed page kept its browser window until the browser exited, 15-60 MB of parent RSS each, growing without bound across crashes. Playwright's own Juggler has the same code and the same leak. The crashed tab is now closed on the next tick after the crash is handled. A persistent launch has no -silent survival area, so closing its last window would quit the browser; there the crashed tab is kept until another tab opens. test_the_parent_stays_flat_across_content_crashes passed while leaking. #785's warm-up moved its baseline past the first context's 150-200 MB, which left three crashes' worth of this leak under the 400 MB bound. It now compares 8 context cycles with a content crash each against 8 without, after two warm-up cycles, and fails if the crashes add 100 MB or more. Both numbers print on every run. Co-Authored-By: Claude Opus 5.5 (1M context) (cherry picked from commit bfd93987c691bdcb548e343372d2841366e145ef) --- additions/juggler/TargetRegistry.js | 48 ++++++++++- additions/juggler/components/Juggler.js | 2 +- native-tests/test_crash_recovery.py | 101 +++++++++++++++++------- 3 files changed, 119 insertions(+), 32 deletions(-) diff --git a/additions/juggler/TargetRegistry.js b/additions/juggler/TargetRegistry.js index e5dc2c7ff..20a83a03b 100644 --- a/additions/juggler/TargetRegistry.js +++ b/additions/juggler/TargetRegistry.js @@ -146,10 +146,16 @@ export class TargetRegistry { return TargetRegistry._instance || null; } - constructor() { + constructor({ lastWindowQuits = true } = {}) { helper.decorateAsEventEmitter(this); TargetRegistry._instance = this; + // False when Juggler holds the last-window-closing survival area (-silent, + // every non-persistent launch); a persistent launch quits with its last window. + this._lastWindowQuits = lastWindowQuits; + // Crashed pages whose tab is the browser's last; see _closeCrashedTab. + this._retainedCrashedTargets = new Set(); + this._browserContextIdToBrowserContext = new Map(); this._userContextIdToBrowserContext = new Map(); this._browserToTarget = new Map(); @@ -189,6 +195,14 @@ export class TargetRegistry { return; target.emit(PageTarget.Events.Crashed); target.dispose(); + // dispose() detaches the page from every client and drops it from its + // context, so nothing could close this tab afterwards: context.close() + // only closes the pages it still tracks, and the default context closes + // none. Each crashed page kept its window alive until the browser + // exited -- 15-60 MB of parent RSS apiece, growing without bound across + // repeated crashes (#762's scraper). Deferred so the tab is not torn + // down inside Gecko's own crash notification. + setTimeout(() => this._closeCrashedTab(target), 0); } }, 'oop-frameloader-crashed'); @@ -215,6 +229,11 @@ export class TargetRegistry { target.updateViewportSize(); if (browserContext.videoRecordingOptions) target._startVideoRecording(browserContext.videoRecordingOptions); + + // Another tab exists now, so a crashed page kept as the last one can go. + for (const crashed of this._retainedCrashedTargets) + setTimeout(() => this._closeCrashedTab(crashed), 0); + this._retainedCrashedTargets.clear(); }; const onTabCloseListener = event => { @@ -369,6 +388,16 @@ export class TargetRegistry { return this._userContextIdToBrowserContext.get(userContextId); } + _closeCrashedTab(target) { + if (this._lastWindowQuits && target.isLastTab()) { + // Closing it would quit a persistent-context browser under the client. + // Keep it until another tab opens; onTabOpenListener comes back for it. + this._retainedCrashedTargets.add(target); + return; + } + target.closeCrashedTab(); + } + async newPage({browserContextId}) { const result = globalNewPageChain.then(() => this._newPageInternal({browserContextId})); globalNewPageChain = result.catch(error => { /* swallow errors to keep chain running */ }); @@ -822,6 +851,23 @@ export class PageTarget { }); } + closeCrashedTab() { + // Its window or context may have closed it first. + if (!this._tab.isConnected || this._tab.closing) + return; + this.close(); + } + + isLastTab() { + if (this._gBrowser.tabs.length > 1) + return false; + for (const win of Services.wm.getEnumerator('navigator:browser')) { + if (win !== this._window && !win.closed) + return false; + } + return true; + } + channel() { return this._channel; } diff --git a/additions/juggler/components/Juggler.js b/additions/juggler/components/Juggler.js index 62338b08c..edc38d409 100644 --- a/additions/juggler/components/Juggler.js +++ b/additions/juggler/components/Juggler.js @@ -92,7 +92,7 @@ export class Juggler { case "final-ui-startup": Services.obs.removeObserver(this, topic); - const targetRegistry = new TargetRegistry(); + const targetRegistry = new TargetRegistry({ lastWindowQuits: !this._silent }); new NetworkObserver(targetRegistry); const loadStyleSheet = () => { diff --git a/native-tests/test_crash_recovery.py b/native-tests/test_crash_recovery.py index e5ea3dd85..734798866 100644 --- a/native-tests/test_crash_recovery.py +++ b/native-tests/test_crash_recovery.py @@ -26,6 +26,7 @@ from __future__ import annotations import asyncio +import enum import os import time from pathlib import Path @@ -627,45 +628,85 @@ async def test_repeated_content_crashes_do_not_accumulate(binary, psutil_mod, le leak_check.assert_clean() -async def test_the_parent_stays_flat_across_content_crashes(binary, psutil_mod, leak_check): +class Cycle(enum.Enum): + CONTROL = "control" + CRASH = "crash" + + +# The parent's first context costs it 150-200 MB with no crash at all, and the +# second still 10-40 MB. A baseline taken before them counts that as growth. +WARMUP_CYCLES = 2 +# Per phase. Long enough for a per-crash leak to outgrow the +-50 MB a phase +# wanders by on its own. +PHASE_CYCLES = 8 +# How much more the crash phase may grow the parent than the control phase. +# Measured locally, 18 and 20 runs: -105..+26 MB with crashed tabs closed, +# +132..+591 MB when every crashed page kept its window open. +CRASH_EXCESS_LIMIT_MB = 100 + + +async def _context_cycle(psutil_mod, browser, cycle: Cycle): + """One context's life: open a page, maybe lose its content process, close.""" + if cycle is Cycle.CRASH: + context, _ = await _content_crash(psutil_mod, browser) + else: + context, _ = await a_page(browser) + await asyncio.sleep(1.5) + try: + await bounded(close_bounded(context, 30, "context.close()"), 40, "close") + except BaseException: + pass + + +async def test_the_parent_stays_flat_across_content_crashes(binary, psutil_mod, leak_check, capsys): """#762 measured the parent at 479 MB, flat, while a child reached 15 GB. That containment is the property worth pinning. If a dying content process dragged the parent up with it, one bad page would take out every other tab in the browser rather than just its own. + + The parent grows on context cycles whether or not anything crashes, so the + crash phase is measured against a control phase of the same cycles without + the kill. A raw bound on the crash phase alone hid a real leak: every + crashed page kept its browser window open, 15-60 MB each. """ manager, browser = await open_browser(binary) - parents = wait_for_process(psutil_mod, BROWSER) - parent = parents[0] - # The baseline follows one clean context. The parent's first context costs - # it 150-200 MB with no crash at all, and a baseline taken before that - # counted the warm-up as crash growth: 330-370 MB of the 400 MB allowance - # locally, and over it on a CI runner. - context, _ = await a_page(browser) - await close_bounded(context, 30, "the warm-up context") - baseline = parent.memory_info().rss + parent = wait_for_process(psutil_mod, BROWSER)[0] - for _ in range(3): - context, _ = await _content_crash(psutil_mod, browser) - await asyncio.sleep(1.5) + def rss(): try: - await bounded(close_bounded(context, 30, "context.close()"), 40, "close") - except BaseException: - pass - - try: - after = parent.memory_info().rss - except psutil_mod.NoSuchProcess: - raise AssertionError( - "the parent process died along with its content processes. #762's whole " - "point is that the parent is unaffected." - ) from None - - growth_mb = (after - baseline) / (1024 * 1024) - assert growth_mb < 400, ( - f"the browser parent grew {growth_mb:.0f} MB across three content-process " - f"crashes ({baseline / 1e6:.0f} MB -> {after / 1e6:.0f} MB). A crashing child " - "should not drag the parent up with it." + return parent.memory_info().rss + except psutil_mod.NoSuchProcess: + raise AssertionError( + "the parent process died along with its content processes. #762's whole " + "point is that the parent is unaffected." + ) from None + + async def phase(cycle: Cycle, count: int) -> float: + before = rss() + for _ in range(count): + await _context_cycle(psutil_mod, browser, cycle) + return (rss() - before) / (1024 * 1024) + + await phase(Cycle.CONTROL, WARMUP_CYCLES) + control_mb = await phase(Cycle.CONTROL, PHASE_CYCLES) + crash_mb = await phase(Cycle.CRASH, PHASE_CYCLES) + excess_mb = crash_mb - control_mb + + # On every run, not just a failing one, so CI logs carry the distribution + # the limit is set from. + with capsys.disabled(): + print( + f"\n[parent-flat] parent RSS over {PHASE_CYCLES} context cycles: control " + f"{control_mb:+.0f} MB, with a content crash each {crash_mb:+.0f} MB, " + f"excess {excess_mb:+.0f} MB (limit {CRASH_EXCESS_LIMIT_MB})" + ) + + assert excess_mb < CRASH_EXCESS_LIMIT_MB, ( + f"the browser parent grew {crash_mb:.0f} MB across {PHASE_CYCLES} content-process " + f"crashes against {control_mb:.0f} MB for the same cycles without one: " + f"{excess_mb:.0f} MB the crashes alone left behind. A crashing child should " + "not drag the parent up with it." ) await teardown(manager, "teardown after measuring parent growth") From 22a9bc581f149095bbcef6819fd02d049d6a5adb Mon Sep 17 00:00:00 2001 From: Jake Writer Date: Sun, 27 Sep 2026 11:57:27 -0600 Subject: [PATCH 09/12] test(guards): a click in a touch context keeps its pointer events Responsive Design Mode plus has_touch swallowed a mouse click's pointer events: page.click() fired mousedown, mouseup and click with no pointerdown or pointerup, which stock Firefox never does. Found while verifying #798, which fixes it by enabling RDM only for isMobile. viewport-no-rdm now compares a click in a has_touch context with one on the launch-level page. On beta.31 with main's Juggler the guard fails on both checks; with this branch's Juggler it passes. Co-Authored-By: Claude Opus 5.5 --- tests/patches/viewport-no-rdm.py | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/tests/patches/viewport-no-rdm.py b/tests/patches/viewport-no-rdm.py index 4d16681fb..99d2ba347 100644 --- a/tests/patches/viewport-no-rdm.py +++ b/tests/patches/viewport-no-rdm.py @@ -19,6 +19,12 @@ - a context with a viewport and is_mobile=True, which must still get RDM's overlay scrollbars, so the option the caller asked for is not dropped. +RDM plus touch also swallowed the pointer events of a mouse click +(PointerEventHandler): in a desktop context with has_touch=True, page.click() +fired mousedown, mouseup and click with no pointerdown or pointerup, which +stock Firefox never does. The guard clicks a button in such a context and +requires the same event sequence as on the launch-level page. + python tests/patches/viewport-no-rdm.py """ @@ -33,6 +39,13 @@ PAGE = """
""" +# The events land in an attribute: page.evaluate runs in an isolated world and +# cannot read the page's globals. +CLICK_PAGE = """""" + MEASURE = """() => { const b = document.getElementById('b'); return { @@ -48,6 +61,12 @@ def measure(page) -> dict: return page.evaluate(MEASURE) +def click_events(page) -> str: + page.set_content(CLICK_PAGE) + page.click("#t") + return page.get_attribute("#t", "data-ev") or "" + + def main() -> int: from camoufox.sync_api import Camoufox @@ -58,10 +77,14 @@ def main() -> int: control = measure(browser.new_page(no_viewport=True)) emulated = measure(browser.new_context(viewport=VIEWPORT).new_page()) mobile = measure(browser.new_context(viewport=VIEWPORT, is_mobile=True).new_page()) + control_click = click_events(browser.new_page(no_viewport=True)) + touch_click = click_events(browser.new_context(viewport=VIEWPORT, has_touch=True).new_page()) print(f" launch page: {control}") print(f" viewport context: {emulated}") print(f" is_mobile: {mobile}") + print(f" click, launch page: {control_click}") + print(f" click, viewport + has_touch: {touch_click}") failures = [] if control["page"] <= 0 or control["box"] <= 0: @@ -76,12 +99,18 @@ def main() -> int: failures.append( f"is_mobile=True did not enable Responsive Design Mode: scrollbars measure " f"{mobile['page']}/{mobile['box']} px, not RDM's 0/0 overlay") + if "pointerdown" not in control_click: + failures.append(f"a click on the launch page fired no pointerdown ({control_click!r}): the check is vacuous") + if touch_click != control_click: + failures.append( + f"a click with has_touch fired {touch_click!r}, not {control_click!r} " + "(Responsive Design Mode drops a mouse click's pointer events)") for f in failures: print(f"FAIL: {f}") if failures: return 1 - print("PASS: a viewport keeps the platform's scrollbars; only is_mobile enables Responsive Design Mode.") + print("PASS: a viewport keeps the platform's scrollbars; only is_mobile enables Responsive Design Mode, and a click keeps its pointer events.") return 0 From 8081061156dbd2448bc25a46b7afabc83a5f844c Mon Sep 17 00:00:00 2001 From: Jake Writer Date: Sun, 27 Sep 2026 12:55:45 -0600 Subject: [PATCH 10/12] fix(juggler): never enter Responsive Design Mode, and warn about is_mobile #798 left RDM on for isMobile, as Playwright's Juggler does. RDM matches no real browser: Firefox for Android never runs it, and under touch emulation it drops a mouse click's pointer events, which no device does. Camoufox only has desktop identities, so an is_mobile context was a desktop UA, platform, fonts and GPU with devtools' mobile mode on top. Juggler now keeps inRDMPane off for every page, is_mobile included, and the isMobile plumbing #798 added is gone again. viewport-no-rdm requires is_mobile=True to keep the platform's scrollbars too. Both launchers warn instead (warnings.yml is_mobile): on new_page() / new_context(is_mobile=True) of a Camoufox browser, and on is_mobile passed to launch_options() for a persistent context. has_touch, device_scale_factor and viewport keep working without RDM. Upstream playwright-python skips its isMobile tests on Firefox in 1.61-1.63, so no skiplist entries are needed. On beta.31 with this Juggler in omni.ja, viewport-no-rdm passes: 12/12 px of scrollbar with no viewport, with a viewport, and with is_mobile, and a has_touch click fires pointerdown and pointerup. pythonlib 411 passed; typescript 584 passed. Co-Authored-By: Claude Opus 5.5 --- additions/juggler/TargetRegistry.js | 20 +++++++------ additions/juggler/protocol/PageHandler.js | 4 +-- pythonlib/camoufox/async_api.py | 2 ++ pythonlib/camoufox/sync_api.py | 2 ++ pythonlib/camoufox/utils.py | 24 +++++++++++++++ pythonlib/camoufox/warnings.yml | 7 +++++ pythonlib/tests/test_identity_salt.py | 8 +++++ pythonlib/tests/test_launch_environment.py | 29 ++++++++++++++++++ tests/patches/viewport-no-rdm.py | 23 +++++++++------ typescript/src/sync_api.ts | 2 ++ typescript/src/utils.ts | 21 +++++++++++++ typescript/tests/launch.test.ts | 34 ++++++++++++++++++++++ 12 files changed, 156 insertions(+), 20 deletions(-) diff --git a/additions/juggler/TargetRegistry.js b/additions/juggler/TargetRegistry.js index 20a83a03b..70b12160c 100644 --- a/additions/juggler/TargetRegistry.js +++ b/additions/juggler/TargetRegistry.js @@ -513,7 +513,6 @@ export class PageTarget { this._linkedBrowser = tab.linkedBrowser; this._browserContext = browserContext; this._viewportSize = undefined; - this._isMobile = undefined; this._zoom = 1; this._initialDPPX = this._linkedBrowser.browsingContext.overrideDPPX; this._url = 'about:blank'; @@ -702,10 +701,16 @@ export class PageTarget { } updateMobileEmulation(browsingContext = undefined) { - // Responsive Design Mode is devtools' mobile mode (overlay scrollbars, and - // RDM branches in screen, window and navigator getters), all readable by - // the page. As in Playwright's Juggler, only isMobile asks for it. - (browsingContext || this._linkedBrowser.browsingContext).inRDMPane = !!(this._isMobile ?? this._browserContext.isMobile); + // Responsive Design Mode is devtools' mobile mode: overlay scrollbars, a + // mouse click's pointer events dropped under touch emulation, and RDM + // branches in screen, window and navigator getters, all readable by the + // page. It matches no real browser -- Firefox for Android never runs it -- + // and Camoufox has only desktop identities, so it stays off even for + // isMobile. Playwright's Juggler turns it on for isMobile + // (microsoft/playwright#41859); this is a deliberate difference, and the + // launchers warn when isMobile is passed. The viewport does not need RDM: + // the element's size sets it. + (browsingContext || this._linkedBrowser.browsingContext).inRDMPane = false; } async updateZoom(browsingContext = undefined) { @@ -831,9 +836,8 @@ export class PageTarget { await this._channel.connect('').send('setInterceptFileChooserDialog', enabled).catch(e => {}); } - async setViewportSize(viewportSize, isMobile) { + async setViewportSize(viewportSize) { this._viewportSize = viewportSize; - this._isMobile = isMobile; await this.updateViewportSize(); } @@ -1253,7 +1257,6 @@ class BrowserContext { this.downloadOptions = undefined; this.defaultViewportSize = undefined; this.deviceScaleFactor = undefined; - this.isMobile = undefined; this.defaultUserAgent = null; this.defaultPlatform = null; this.touchOverride = false; @@ -1379,7 +1382,6 @@ class BrowserContext { async setDefaultViewport(viewport) { this.defaultViewportSize = viewport ? viewport.viewportSize : undefined; this.deviceScaleFactor = viewport ? viewport.deviceScaleFactor : undefined; - this.isMobile = viewport ? viewport.isMobile : undefined; await Promise.all(Array.from(this.pages).map(page => page.updateViewportSize())); } diff --git a/additions/juggler/protocol/PageHandler.js b/additions/juggler/protocol/PageHandler.js index 894ad0c62..774320562 100644 --- a/additions/juggler/protocol/PageHandler.js +++ b/additions/juggler/protocol/PageHandler.js @@ -241,8 +241,8 @@ export class PageHandler { }); } - async ['Page.setViewportSize']({viewportSize, isMobile}) { - await this._pageTarget.setViewportSize(viewportSize === null ? undefined : viewportSize, isMobile); + async ['Page.setViewportSize']({viewportSize}) { + await this._pageTarget.setViewportSize(viewportSize === null ? undefined : viewportSize); } async ['Page.setZoom']({zoom}) { diff --git a/pythonlib/camoufox/async_api.py b/pythonlib/camoufox/async_api.py index 52cb49892..dcb35a219 100644 --- a/pythonlib/camoufox/async_api.py +++ b/pythonlib/camoufox/async_api.py @@ -17,6 +17,7 @@ from .utils import ( async_attach_vd, attach_no_viewport_default, + attach_desktop_only_warning, attach_stock_media_defaults, launch_options, STOCK_MEDIA_DEFAULTS, @@ -173,6 +174,7 @@ async def _launch( if no_viewport_default: attach_no_viewport_default(browser) attach_stock_media_defaults(browser) + attach_desktop_only_warning(browser) return await async_attach_vd(browser, virtual_display) diff --git a/pythonlib/camoufox/sync_api.py b/pythonlib/camoufox/sync_api.py index 5cd65d7bd..ea8263520 100644 --- a/pythonlib/camoufox/sync_api.py +++ b/pythonlib/camoufox/sync_api.py @@ -14,6 +14,7 @@ from .ip import Proxy, proxy_exit_geo from .utils import ( attach_no_viewport_default, + attach_desktop_only_warning, attach_stock_media_defaults, launch_options, STOCK_MEDIA_DEFAULTS, @@ -148,6 +149,7 @@ def NewBrowser( if no_viewport_default: attach_no_viewport_default(browser) attach_stock_media_defaults(browser) + attach_desktop_only_warning(browser) return sync_attach_vd(browser, virtual_display) finally: if pid: diff --git a/pythonlib/camoufox/utils.py b/pythonlib/camoufox/utils.py index c0889da74..b3e7f9f3d 100644 --- a/pythonlib/camoufox/utils.py +++ b/pythonlib/camoufox/utils.py @@ -683,6 +683,27 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: return target +def attach_desktop_only_warning(target: Any) -> Any: + """Warn when new_page()/new_context() asks for is_mobile: Camoufox only has + desktop identities, and Juggler ignores the option (TargetRegistry.js).""" + for name in ('new_page', 'new_context'): + original = getattr(target, name, None) + if original is None: + continue + + def wrap(original: Any) -> Any: + @wraps(original) + def wrapper(*args: Any, **kwargs: Any) -> Any: + if kwargs.get('is_mobile'): + LeakWarning.warn('is_mobile') + return original(*args, **kwargs) + + return wrapper + + setattr(target, name, wrap(original)) + return target + + def attach_no_viewport_default(target: Any) -> Any: """ Default new_page()/new_context() to no_viewport=True. @@ -1417,6 +1438,9 @@ def launch_options( if disable_coop: LeakWarning.warn('disable_coop', i_know_what_im_doing) firefox_user_prefs['browser.tabs.remote.useCrossOriginOpenerPolicy'] = False + # A persistent context takes its context options here. + if launch_options.get('is_mobile'): + LeakWarning.warn('is_mobile', i_know_what_im_doing) # Drop values the source supplied that this identity cannot keep, before the # WebGL pool below defers to them (a preset's own GPU pair wins over diff --git a/pythonlib/camoufox/warnings.yml b/pythonlib/camoufox/warnings.yml index da49b9a17..91d9e7396 100644 --- a/pythonlib/camoufox/warnings.yml +++ b/pythonlib/camoufox/warnings.yml @@ -41,6 +41,13 @@ instant_animations: >- never waits on one. A page can see it: getComputedTiming() reports a duration of 0 where stock Firefox reports the real one. +is_mobile: >- + Camoufox is built for desktops and only draws desktop identities, so is_mobile + cannot produce a believable phone and will leak: the page still sees a desktop + User-Agent, platform, fonts, GPU and screen. Camoufox ignores is_mobile rather + than put the page in Responsive Design Mode, which a page can read too. + Use has_touch, device_scale_factor and viewport for the parts you need. + block_webgl: >- Disabling WebGL is not recommended. Many WAFs will check if WebGL is enabled. diff --git a/pythonlib/tests/test_identity_salt.py b/pythonlib/tests/test_identity_salt.py index 2e2d9e493..57fb5f202 100644 --- a/pythonlib/tests/test_identity_salt.py +++ b/pythonlib/tests/test_identity_salt.py @@ -191,3 +191,11 @@ def test_instant_animations_warn_that_they_are_detectable(): with pytest.warns(LeakWarning, match="getComputedTiming"): launch(config={"instantAnimations": True}, i_know_what_im_doing=False) + + +def test_is_mobile_warns_that_camoufox_is_desktop_only(): + """A persistent context takes its context options at launch.""" + from camoufox._warnings import LeakWarning + + with pytest.warns(LeakWarning, match="built for desktops"): + launch(is_mobile=True, i_know_what_im_doing=False) diff --git a/pythonlib/tests/test_launch_environment.py b/pythonlib/tests/test_launch_environment.py index 02cceef93..db393b2ef 100644 --- a/pythonlib/tests/test_launch_environment.py +++ b/pythonlib/tests/test_launch_environment.py @@ -388,3 +388,32 @@ def new_context(self, **kwargs): assert browser.kwargs["forced_colors"] == "active" # the ones the caller left alone still follow the host assert browser.kwargs["reduced_motion"] == "no-override" + + +class TestDesktopOnlyWarning: + """Camoufox has only desktop identities and Juggler never enters Responsive + Design Mode, so is_mobile on new_page()/new_context() gets a LeakWarning.""" + + class FakeBrowser: + def new_page(self, **kwargs): + return kwargs + + def new_context(self, **kwargs): + return kwargs + + def test_is_mobile_warns(self): + from camoufox._warnings import LeakWarning + + browser = utils.attach_desktop_only_warning(self.FakeBrowser()) + for create in (browser.new_page, browser.new_context): + with pytest.warns(LeakWarning, match="built for desktops"): + assert create(is_mobile=True) == {"is_mobile": True} + + def test_a_desktop_context_does_not(self): + import warnings + + browser = utils.attach_desktop_only_warning(self.FakeBrowser()) + with warnings.catch_warnings(): + warnings.simplefilter("error") + browser.new_context(viewport={"width": 800, "height": 600}, is_mobile=False) + browser.new_page() diff --git a/tests/patches/viewport-no-rdm.py b/tests/patches/viewport-no-rdm.py index 99d2ba347..b3e4b4964 100644 --- a/tests/patches/viewport-no-rdm.py +++ b/tests/patches/viewport-no-rdm.py @@ -10,14 +10,15 @@ launch-level page and 0 px in any context with a viewport. Playwright's Juggler now enables RDM only for `isMobile` -(microsoft/playwright#41859), and so does Camoufox's. The guard pins classic -scrollbars and measures the scrollbar gutter three ways: +(microsoft/playwright#41859). Camoufox's never does: RDM matches no real +browser (Firefox for Android does not run it), and Camoufox only has desktop +identities, so the launchers warn about is_mobile instead. The guard pins +classic scrollbars and measures the scrollbar gutter three ways: - a launch-level page, the control: it must be non-zero, or the check is vacuous; - a context with a viewport, which must match the control; - - a context with a viewport and is_mobile=True, which must still get RDM's - overlay scrollbars, so the option the caller asked for is not dropped. + - a context with a viewport and is_mobile=True, which must match it too. RDM plus touch also swallowed the pointer events of a mouse click (PointerEventHandler): in a desktop context with has_touch=True, page.click() @@ -29,6 +30,7 @@ """ import sys +import warnings from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) @@ -68,6 +70,7 @@ def click_events(page) -> str: def main() -> int: + from camoufox._warnings import LeakWarning from camoufox.sync_api import Camoufox binary = resolve_binary() @@ -76,7 +79,9 @@ def main() -> int: i_know_what_im_doing=True) as browser: control = measure(browser.new_page(no_viewport=True)) emulated = measure(browser.new_context(viewport=VIEWPORT).new_page()) - mobile = measure(browser.new_context(viewport=VIEWPORT, is_mobile=True).new_page()) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", LeakWarning) # is_mobile warns; that is the point + mobile = measure(browser.new_context(viewport=VIEWPORT, is_mobile=True).new_page()) control_click = click_events(browser.new_page(no_viewport=True)) touch_click = click_events(browser.new_context(viewport=VIEWPORT, has_touch=True).new_page()) @@ -95,10 +100,10 @@ def main() -> int: failures.append( f"scrollbar width differs with a viewport: {emulated['page']}/{emulated['box']} px " f"vs {control['page']}/{control['box']} px without (Responsive Design Mode)") - if (mobile["page"], mobile["box"]) != (0, 0): + if (mobile["page"], mobile["box"]) != (control["page"], control["box"]): failures.append( - f"is_mobile=True did not enable Responsive Design Mode: scrollbars measure " - f"{mobile['page']}/{mobile['box']} px, not RDM's 0/0 overlay") + f"scrollbar width differs with is_mobile=True: {mobile['page']}/{mobile['box']} px " + f"vs {control['page']}/{control['box']} px without (Responsive Design Mode)") if "pointerdown" not in control_click: failures.append(f"a click on the launch page fired no pointerdown ({control_click!r}): the check is vacuous") if touch_click != control_click: @@ -110,7 +115,7 @@ def main() -> int: print(f"FAIL: {f}") if failures: return 1 - print("PASS: a viewport keeps the platform's scrollbars; only is_mobile enables Responsive Design Mode, and a click keeps its pointer events.") + print("PASS: no context enters Responsive Design Mode -- a viewport keeps the platform's scrollbars, is_mobile included, and a click keeps its pointer events.") return 0 diff --git a/typescript/src/sync_api.ts b/typescript/src/sync_api.ts index 1df12a286..b1cd45ac6 100644 --- a/typescript/src/sync_api.ts +++ b/typescript/src/sync_api.ts @@ -20,6 +20,7 @@ import { ensureModel } from "./fpgen/index.js"; import { type ProxyConfig, ProxyHelper, proxyExitGeo } from "./ip.js"; import { applyNoViewport, + attachDesktopOnlyWarning, attachNoViewportDefault, attachStockMediaDefaults, attachVirtualDisplay, @@ -193,6 +194,7 @@ async function launchWith( attachNoViewportDefault(browser); } attachStockMediaDefaults(browser); + attachDesktopOnlyWarning(browser); return attachVirtualDisplay(browser, virtualDisplay); } diff --git a/typescript/src/utils.ts b/typescript/src/utils.ts index 443b7136f..7c5a42f29 100644 --- a/typescript/src/utils.ts +++ b/typescript/src/utils.ts @@ -990,6 +990,23 @@ export function attachStockMediaDefaults(target: T): T { return target; } +/** + * Warn when newPage()/newContext() asks for isMobile: Camoufox only has desktop + * identities, and Juggler ignores the option (TargetRegistry.js). + */ +export function attachDesktopOnlyWarning(target: T): T { + for (const name of ["newPage", "newContext"] as const) { + const original = (target as any)[name]; + if (typeof original !== "function") continue; + (target as any)[name] = (options?: Record, ...rest: any[]) => { + if (options?.isMobile || options?.is_mobile) + LeakWarning.warn("is_mobile"); + return original.call(target, options, ...rest); + }; + } + return target; +} + /** * Normalise a context-options object onto the JS API's `viewport: null`, * defaulting to it when the caller expressed no preference. Playwright-Python @@ -1679,6 +1696,10 @@ export async function launchOptions({ firefox_user_prefs["browser.tabs.remote.useCrossOriginOpenerPolicy"] = false; } + // A persistent context takes its context options here. + if (passthrough.is_mobile || passthrough.isMobile) { + LeakWarning.warn("is_mobile", i_know_what_im_doing); + } // Drop values the source supplied that this identity cannot keep, before the // WebGL pool below defers to them. Here, not earlier, because the check diff --git a/typescript/tests/launch.test.ts b/typescript/tests/launch.test.ts index 7fa7bdbb3..b0f9e18fd 100644 --- a/typescript/tests/launch.test.ts +++ b/typescript/tests/launch.test.ts @@ -384,6 +384,26 @@ describe("test_launch_environment: stock media defaults", () => { expect(opts.forcedColors).toBe("active"); expect(opts.reducedMotion).toBeNull(); }); + + it("isMobile warns that Camoufox is desktop-only", async () => { + const browser = utils.attachDesktopOnlyWarning(new FakeBrowser()); + for (const create of ["newPage", "newContext"] as const) { + const { warnings: caught } = await warnings.recordWarnings(async () => + browser[create]({ isMobile: true }), + ); + expect( + caught.some( + (w) => + w.category === "LeakWarning" && + w.message.includes("built for desktops"), + ), + ).toBe(true); + } + const { warnings: none } = await warnings.recordWarnings(async () => + browser.newContext({ isMobile: false }), + ); + expect(none).toEqual([]); + }); }); describe("test_viewport_default", () => { @@ -886,6 +906,20 @@ describe.skipIf(!modelReady)( expect(warned).toBe(true); }); + it("is_mobile warns that Camoufox is desktop-only", async () => { + const { warned } = await launchWarning( + { + os: "windows", + executable_path: BUNDLE_EXE, + is_mobile: true, + i_know_what_im_doing: false, + }, + "LeakWarning", + "built for desktops", + ); + expect(warned).toBe(true); + }); + it("keeps the caller's seeds", async () => { const config = await launchConfig({ os: "linux", From 12a1bb4fc6bc8fbdd88db2fd647bd1d554473f13 Mon Sep 17 00:00:00 2001 From: Jake Writer Date: Sun, 27 Sep 2026 13:21:01 -0600 Subject: [PATCH 11/12] fix(fingerprints): a NewContext Linux identity is Linux in platform and oscpu too fpgen's Linux pool now and then pairs a Linux user agent with navigator.platform Win32 and a Windows oscpu. launch_options() corrects that with fix_navigator_arch(); generate_context_fingerprint() never called it, so 24 of 1,500 Linux NewContext identities (1.6%) said Win32 under a Linux UA -- and because that path reads the OS for fonts and voices from the platform, they drew Windows fonts and voices as well. It now applies the same fix right after the fpgen draw, in pythonlib and in the TypeScript twin. The fix draws nothing, so the parity goldens are unchanged. The new tests feed the context path a real Linux draw with the Windows platform and oscpu, and fail without the fix ('Win32' != 'Linux x86_64') in both ports. After it: 0 of 1,500 sampled contexts mismatch. pythonlib 412 passed; typescript 585 passed. Co-Authored-By: Claude Opus 5.5 --- pythonlib/camoufox/fingerprints.py | 6 ++++++ pythonlib/tests/test_identity_salt.py | 22 ++++++++++++++++++++++ typescript/src/fingerprints.ts | 6 ++++++ typescript/tests/fingerprints.test.ts | 27 ++++++++++++++++++++++++++- 4 files changed, 60 insertions(+), 1 deletion(-) diff --git a/pythonlib/camoufox/fingerprints.py b/pythonlib/camoufox/fingerprints.py index cb18defab..42c8ba18b 100644 --- a/pythonlib/camoufox/fingerprints.py +++ b/pythonlib/camoufox/fingerprints.py @@ -1619,6 +1619,12 @@ def generate_context_fingerprint( # Fall back to synthetic generation fp = generate_fingerprint(os=os) config = from_fpgen(fp, ff_version) + # fpgen's Linux pool now and then pairs the Linux UA with a Windows + # platform and oscpu. launch_options() corrects that; this path did + # not, so ~1.6% of Linux contexts said Win32 -- and, since the OS below + # is read from the platform, drew Windows fonts and voices as well. + if 'Linux' in str(config.get('navigator.userAgent', '')): + fix_navigator_arch(config, 'lin') # A fresh identity: every seeded draw below gets its own salt. _salt = identity_salt() diff --git a/pythonlib/tests/test_identity_salt.py b/pythonlib/tests/test_identity_salt.py index 57fb5f202..7a386eb1f 100644 --- a/pythonlib/tests/test_identity_salt.py +++ b/pythonlib/tests/test_identity_salt.py @@ -199,3 +199,25 @@ def test_is_mobile_warns_that_camoufox_is_desktop_only(): with pytest.warns(LeakWarning, match="built for desktops"): launch(is_mobile=True, i_know_what_im_doing=False) + + +def test_a_context_with_a_linux_ua_is_linux_throughout(monkeypatch): + """fpgen's Linux pool now and then pairs the Linux UA with platform Win32 and + a Windows oscpu (~1.6% of NewContext Linux identities). launch_options() + fixed that and generate_context_fingerprint() did not -- and it reads the OS + for fonts and voices from the platform, so those came out Windows too.""" + real = fp.generate_fingerprint + + def mismatched(**kwargs): + drawn = real(**kwargs) + drawn["navigator"]["platform"] = "Win32" + drawn["navigator"]["oscpu"] = "Windows NT 10.0; Win64; x64" + return drawn + + monkeypatch.setattr(fp, "generate_fingerprint", mismatched) + config = fp.generate_context_fingerprint(os="linux")["config"] + + assert "Linux x86_64" in config["navigator.userAgent"] + assert config["navigator.platform"] == "Linux x86_64" + assert config["navigator.oscpu"] == "Linux x86_64" + assert "Segoe UI" not in config["fonts"] diff --git a/typescript/src/fingerprints.ts b/typescript/src/fingerprints.ts index a2fb4a09b..897b50529 100644 --- a/typescript/src/fingerprints.ts +++ b/typescript/src/fingerprints.ts @@ -1726,6 +1726,12 @@ export function generateContextFingerprint({ } else { const fp = generateFingerprint({ os: os ?? undefined }); config = fromFpgen(fp, ff_version); + // fpgen's Linux pool now and then pairs the Linux UA with a Windows + // platform and oscpu. launchOptions() corrects that; this path did not, + // so ~1.6% of Linux contexts said Win32 -- and, since the OS below is + // read from the platform, drew Windows fonts and voices as well. + if (pyStr(get(config, "navigator.userAgent", "")).includes("Linux")) + fixNavigatorArch(config, "lin"); // A fresh identity: every seeded draw below gets its own salt. const salt = identitySalt(); diff --git a/typescript/tests/fingerprints.test.ts b/typescript/tests/fingerprints.test.ts index 397037437..0763046aa 100644 --- a/typescript/tests/fingerprints.test.ts +++ b/typescript/tests/fingerprints.test.ts @@ -6,7 +6,7 @@ */ import * as fs from "node:fs"; import * as path from "node:path"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { InvalidIP } from "../src/exceptions.js"; import { appVersionFromUserAgent, @@ -35,6 +35,7 @@ import { setMediaDevicesDefaults, WINDOWS_11_MARKER_FONTS, } from "../src/fingerprints.js"; +import { Generator } from "../src/fpgen/index.js"; import { LOCAL_DATA } from "../src/pkgman.js"; import { MODEL } from "./fpgen-setup.js"; @@ -887,4 +888,28 @@ describe.skipIf(!MODEL.ok)("fpgen generation (needs the model)", () => { expect(fromP.context_options.timezoneId).toBe("Europe/Paris"); expect(fromP.config["navigator.platform"]).toBe("MacIntel"); }); + + it("a context with a Linux UA is Linux throughout", () => { + // fpgen's Linux pool now and then pairs the Linux UA with platform Win32 + // and a Windows oscpu. launchOptions() fixed that and this path did not, + // and it reads the OS for fonts and voices from the platform. + const real = Generator.prototype.generate; + const spy = vi + .spyOn(Generator.prototype, "generate") + .mockImplementation(function (this: Generator, ...args: any[]) { + const drawn = (real as any).apply(this, args); + drawn.navigator.platform = "Win32"; + drawn.navigator.oscpu = "Windows NT 10.0; Win64; x64"; + return drawn; + }); + try { + const { config } = generateContextFingerprint({ os: "linux" }); + expect(config["navigator.userAgent"]).toContain("Linux x86_64"); + expect(config["navigator.platform"]).toBe("Linux x86_64"); + expect(config["navigator.oscpu"]).toBe("Linux x86_64"); + expect(config.fonts).not.toContain("Segoe UI"); + } finally { + spy.mockRestore(); + } + }); }); From e92caec332e70abfa3100f4d58a060160e784965 Mon Sep 17 00:00:00 2001 From: Jake Writer Date: Sun, 27 Sep 2026 21:42:47 +0000 Subject: [PATCH 12/12] CI: release npm with PyPI, split the patch guards by kind, run memory growth on every PR (#789) * ci: split the patch guards by kind, and run memory growth on every PR Patch guards: one 11-minute job ran all 26 guards and the Playwright skiplist audit. Whether the patches apply is the build's check; the guards test that what they do still works, and fall into three kinds, now three jobs beside the skiplist audit: spoofing a spoofed value still reaches the page and holds together automation Playwright stays invisible to the page and never deadlocks it parity what a page, or the OS, can observe matches stock Firefox Each writes its own suite (patch_guards_), so a failure names the kind that broke. GROUPS in ci/run_patch_guards.py assigns every guard to exactly one, and a self-test fails on a guard in none. One job id with a matrix, so everything that needs patch-guards is unchanged. Memory growth: ~38 minutes in one process kept it on the schedule and out of the gate. ci.run_native --shard i/n runs every n-th collected test, and the growth job is a 7-way matrix -- one test per runner, about six minutes each -- on every pull request, required by the summary and the gate. summarize.py already folds -of results back into one suite, as it does for Playwright. CONTRIBUTING.md now says why the stealth check skips on a fork pull request: GitHub gives secrets only to branches in this repository. Co-Authored-By: Claude Opus 5.5 * ci(release): publish to npm after PyPI, from the same commit The two launchers ship at one version, but were released by two unrelated, hand-started workflows, so npm could get a release PyPI did not. "Publish to pypi" is now the one place a release starts: 1. it calls publish-npm.yml as a dry run -- every check, the build, the pack check and `npm publish --dry-run` -- so a broken npm package stops the release before anything is uploaded; 2. it uploads to PyPI; 3. its success triggers publish-npm.yml (workflow_run), which publishes the commit PyPI was released from. publish-npm.yml stays the file that publishes, because npm's trusted publisher is tied to its name. Started by hand it only retries the npm half, and refuses unless PyPI already has the version. Co-Authored-By: Claude Opus 5.5 * test(guards): contentaccessible-parity reads its probe's marked result line The live probe runs in a child process and the parent parsed its whole stdout as JSON. On a machine whose cache has no addons yet, the first Camoufox launch downloads uBlock Origin and prints its progress to stdout first, so the parse failed ("Expecting value: line 2 column 1"). It only ever passed because another guard launched Camoufox earlier in the same job; split into its own leg, it ran first on a fresh runner. Co-Authored-By: Claude Opus 5.5 * ci(guards): group the three guards main added since the split addons-install-once, viewport-no-rdm and worker-config-reads landed on main after the groups were drawn; the one-group-each self-test caught them. Co-Authored-By: Claude Opus 5.5 * test(guards): gfx-probes gives a blocklisted launch a second try The blocklist signature means gfxInfo is empty. Missing probes cause that on every launch; a present glxtest that fails or times out on a loaded runner causes it once in a while, which failed stock parity on this PR. Relaunch once before failing, and print what the probes wrote to stderr. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .github/workflows/publish-npm.yml | 49 +++++++++++++-- .github/workflows/publish-pypi.yml | 14 +++++ .github/workflows/tests.yml | 55 ++++++++++++----- AGENTS.md | 2 +- CONTRIBUTING.md | 4 +- ci/README.md | 17 +++-- ci/run_native.py | 45 ++++++++++++-- ci/run_patch_guards.py | 75 ++++++++++++++++++++--- ci/summarize.py | 4 +- ci/tests/test_ci.py | 65 +++++++++++++++++++- tests/README.md | 19 ++++-- tests/patches/contentaccessible-parity.py | 14 ++++- tests/patches/gfx-probes-packaged.py | 51 ++++++++++----- typescript/README.md | 14 +++-- 14 files changed, 355 insertions(+), 73 deletions(-) diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index b5c8ea356..8f78629c3 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -1,8 +1,19 @@ name: Publish to npm -# The npm twin of publish-pypi.yml: dispatched by hand, checks, builds, verifies -# the tarball, publishes. The package version is typescript/package.json's and -# must equal pythonlib's -- scripts/check-pack.mjs refuses to go on otherwise. +# The npm half of a launcher release. The two packages ship together, at one +# version (scripts/check-pack.mjs refuses a package.json that differs from +# pythonlib's), and "Publish to pypi" is the one place a release starts: +# +# 1. publish-pypi.yml calls this workflow as a dry run -- every check, the +# build, the pack check and `npm publish --dry-run` -- so a broken npm +# package stops the release before anything is uploaded anywhere; +# 2. it uploads to PyPI; +# 3. its success triggers this workflow again (workflow_run), which publishes +# the same commit to npm. +# +# Started by hand, this workflow only retries step 3 -- after an npm outage, +# say -- and refuses unless PyPI already has this version, so npm can never get +# a release PyPI does not. # # Authentication is npm trusted publishing (OIDC): no token is stored anywhere. # npm accepts this workflow's identity because the package's settings on @@ -12,6 +23,14 @@ name: Publish to npm # publisher is configured on a package that already exists. on: + workflow_run: + workflows: ["Publish to pypi"] + types: [completed] + workflow_call: + inputs: + dry_run: + type: boolean + default: true workflow_dispatch: inputs: dry_run: @@ -24,6 +43,8 @@ permissions: jobs: publish: + # After a PyPI run, only a successful one. + if: github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' runs-on: ubuntu-latest permissions: contents: read @@ -37,6 +58,9 @@ jobs: steps: - name: Check out repository uses: actions/checkout@v6 + with: + # The commit PyPI was released from, not whatever main is now. + ref: ${{ github.event.workflow_run.head_sha || github.sha }} - name: Set up Python uses: actions/setup-python@v5 @@ -83,5 +107,22 @@ jobs: - name: Check package run: node scripts/check-pack.mjs + - name: PyPI has this version + # npm never gets a release PyPI does not have. Retried for a few minutes: + # straight after the upload, PyPI's JSON API can lag. + if: ${{ !inputs.dry_run }} + run: | + version=$(node -p "require('./package.json').version") + for attempt in $(seq 1 20); do + if curl -fsS -o /dev/null "https://pypi.org/pypi/camoufox/$version/json"; then + echo "camoufox $version is on PyPI" + exit 0 + fi + sleep 15 + done + echo "::error::camoufox $version is not on PyPI. Release through 'Publish to pypi', which publishes to npm after it." + exit 1 + - name: Publish to npm - run: npm publish --access public ${{ inputs.dry_run && '--dry-run' || '' }} + # A dry run uploads nothing, so it has nothing to attest. + run: npm publish --access public ${{ inputs.dry_run && '--dry-run --provenance=false' || '' }} diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 6cee8f7b6..d70413362 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -6,8 +6,22 @@ on: permissions: contents: read +# The one place a launcher release starts. The npm package is checked first -- +# as a dry run of publish-npm.yml -- so a broken one stops the release before +# PyPI has it; PyPI's success then triggers publish-npm.yml to publish the same +# commit to npm (see the comment there). + jobs: + npm-preflight: + permissions: + contents: read + id-token: write # publish-npm.yml's job declares it; a dry run does not use it + uses: ./.github/workflows/publish-npm.yml + with: + dry_run: true + publish: + needs: npm-preflight runs-on: ubuntu-latest defaults: run: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a53fabe6f..9fc815a2e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -785,9 +785,23 @@ jobs: patch-guards: # Tier 3a: the cheap checks on a fresh browser. If these fail the browser is # broken in an obvious way and tier 3b would only say so more slowly. + # + # Whether the patches APPLY is the build's job; these check that what they + # do still works. One leg per kind of guarantee (ci/run_patch_guards.py + # GROUPS), plus the skiplist audit, so a failure names which kind broke and + # the legs run side by side. One job id, so everything that waits on the + # guards keeps `needs: patch-guards`. if: always() && (needs.build.result == 'success' || needs.fetch-browser.result == 'success') - name: Patch guards + name: ${{ matrix.title }} needs: [resolve, build, fetch-browser] + strategy: + fail-fast: false + matrix: + include: + - {leg: spoofing, title: "Patch guards: spoofing"} + - {leg: automation, title: "Patch guards: automation"} + - {leg: parity, title: "Patch guards: stock parity"} + - {leg: skiplist, title: "Skiplist audit"} runs-on: ubuntu-24.04 timeout-minutes: 60 permissions: @@ -808,8 +822,10 @@ jobs: python3 scripts/pin-fpgen-model.py # Fonts and properties.json are staged into the artifact by the build job; # `make stage-fonts` here would find no source tree and do nothing. - - run: xvfb-run -a python3 -m ci.run_patch_guards --binary "$CAMOUFOX_BINARY" + - if: matrix.leg != 'skiplist' + run: xvfb-run -a python3 -m ci.run_patch_guards --group "${{ matrix.leg }}" --binary "$CAMOUFOX_BINARY" - name: Every skiplist entry is still failing + if: matrix.leg == 'skiplist' # Seconds, because a correct skiplist is short. This is what stops # ci/skiplist.yml turning into a list of tests that would now pass -- # which is what it was: 193 of the 202 tests it skipped passed. @@ -824,7 +840,7 @@ jobs: - uses: actions/upload-artifact@v4 if: always() with: - name: results-patch-guards + name: results-patch-guards-${{ matrix.leg }} path: .ci-work/results/ include-hidden-files: true if-no-files-found: warn @@ -993,21 +1009,25 @@ jobs: # --------------------------------------------------------------------------- growth: - name: Memory growth (scheduled) - # Deliberately NOT in the merge gate. It takes ~37 minutes, because every - # mechanism is churned twice -- at n and 4n -- to measure whether growth - # scales with the count. That is the wrong price to pay on every pull - # request for a detector aimed at a slow-moving class of bug, so it runs on - # the schedule and on demand, and a failure opens a conversation rather than - # blocking a merge. + name: Memory growth ${{ matrix.shard }} + # Every mechanism is churned twice -- at n and 4n -- to measure whether + # growth scales with the count: ~38 minutes in one process, which kept this + # off pull requests. One test per runner (ci.run_native --shard) brings it + # to the length of a Playwright shard, so it runs on every pull request and + # is part of the merge gate. Seven shards for the seven tests in + # native-tests/test_memory_growth.py; with more tests than shards a shard + # simply runs two. needs: [resolve, build, fetch-browser, patch-guards, build-tester] if: >- always() - && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && needs.patch-guards.result == 'success' && needs.build-tester.result == 'success' runs-on: ubuntu-24.04 - timeout-minutes: 120 + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + shard: ["1/7", "2/7", "3/7", "4/7", "5/7", "6/7", "7/7"] permissions: contents: read steps: @@ -1024,11 +1044,12 @@ jobs: # April-2025 model. Install the pinned one first: see # scripts/pin-fpgen-model.py. python3 scripts/pin-fpgen-model.py - - run: xvfb-run -a python3 -m ci.run_native --subset growth --binary "$CAMOUFOX_BINARY" + - run: xvfb-run -a python3 -m ci.run_native --subset growth --shard "${{ matrix.shard }}" --binary "$CAMOUFOX_BINARY" - uses: actions/upload-artifact@v4 if: always() with: - name: results-growth + # A shard's "3/7" cannot go in an artifact name. + name: results-growth-${{ strategy.job-index }} path: .ci-work/results/ include-hidden-files: true if-no-files-found: warn @@ -1086,7 +1107,7 @@ jobs: summary: name: Summary needs: [resolve, static, pythonlib, typescript, build, fetch-browser, playwright, - patch-guards, build-tester, native, typescript-browser, sundial] + patch-guards, build-tester, native, typescript-browser, growth, sundial] if: always() && needs.resolve.result == 'success' runs-on: ubuntu-24.04 permissions: @@ -1126,7 +1147,7 @@ jobs: # summarize report "produced no result file" and fail the gate on # every pull request that did not touch the browser. Which is most of # them, and exactly the cheap path this pipeline advertises. - required="pythonlib typescript typescript_browser native_rules patch_guards skiplist_audit build_tester playwright native_browser" + required="pythonlib typescript typescript_browser native_rules patch_guards_spoofing patch_guards_automation patch_guards_parity skiplist_audit build_tester playwright native_browser native_growth" if [ "${{ needs.resolve.outputs.browser_changed }}" = "true" ]; then required="$required build" fi @@ -1192,7 +1213,7 @@ jobs: # did not run has not passed, and silently skipping one is the cheapest way # to a green tick. needs: [resolve, static, pythonlib, typescript, build, fetch-browser, playwright, - patch-guards, build-tester, native, typescript-browser, sundial, summary] + patch-guards, build-tester, native, typescript-browser, growth, sundial, summary] if: always() runs-on: ubuntu-24.04 permissions: diff --git a/AGENTS.md b/AGENTS.md index 6e58c6c59..2e121bd4a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -173,7 +173,7 @@ tests passed`**. Run the suite that covers your change while you work: | You changed | Run | |---|---| -| Patches, C++, Juggler | `python3 -m ci.run_build_tester --binary ` (the anti-detect suite) and `python3 -m ci.run_patch_guards --binary ` (one guard per spoofing behaviour) | +| Patches, C++, Juggler | `python3 -m ci.run_build_tester --binary ` (the anti-detect suite) and `python3 -m ci.run_patch_guards --binary ` (one guard per shipped behaviour, in three groups: `--group spoofing|automation|parity`) | | Automation behaviour | `make tests`, the upstream Playwright suite with `ci/skiplist.yml` applied | | `pythonlib/` | `python3 -m ci.run_pythonlib` | | `ci/` itself | `python3 -m pytest ci/tests -q` | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 20f9c5c61..9a0ef2373 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,7 +38,9 @@ The engineering rules in [`AGENTS.md`](AGENTS.md) apply to every change, whether ## Testing Requirements -**CI runs everything, on every pull request.** [`.github/workflows/tests.yml`](.github/workflows/tests.yml) builds the browser from your branch when you touch browser sources (and tests against the published release when you do not), then runs the Python and TypeScript package tests, the patch guards, build-tester, the upstream Playwright suite, the leak suite and the stealth check. Branch protection requires exactly one check, **`All tests passed`**, which is green only when every applicable suite is. +**CI runs everything, on every pull request.** [`.github/workflows/tests.yml`](.github/workflows/tests.yml) builds the browser from your branch when you touch browser sources (and tests against the published release when you do not), then runs the Python and TypeScript package tests, the patch guards, build-tester, the upstream Playwright suite, the leak and memory-growth suites, and the stealth check. Branch protection requires exactly one check, **`All tests passed`**, which is green only when every applicable suite is. + +The stealth check needs a credential, and GitHub gives secrets only to pull requests whose branch is in this repository, never to one from a fork. If you have write access, push your branch here rather than to a fork, so it runs; on a fork pull request it is skipped, and says so in the summary. So there is nothing to attach to the pull request by hand. The old process — run the suites locally, screenshot the output, paste it in — was unenforceable: nothing checked that the browser in the screenshot was built from the branch under review. If you want a report in the description anyway, CI leaves one as a comment on the pull request. diff --git a/ci/README.md b/ci/README.md index 8442faf43..5810d9701 100644 --- a/ci/README.md +++ b/ci/README.md @@ -9,14 +9,15 @@ pass", not two. resolve ── static ─────────────── lint, tribal rules, skiplist, self-tests (seconds) ├─ typescript ────── type check, lint, vitest, golden parity └─ pythonlib ─────── the package's own tests (a minute) - └─ build or fetch ─┬─ patch guards ─────── one per spoofing patch, + skiplist audit + └─ build or fetch ─┬─ patch guards × 3 ─── spoofing, automation, stock parity + ├─ skiplist audit ───── every skip still fails ├─ build-tester ─────── 8 fingerprint profiles ├─ typescript-browser ─ the npm launcher end to end └─ once guards and build-tester pass: ├─ playwright × 6 shards (conformance + our own) ├─ native ───────── leaks, contexts, crash recovery ├─ sundial ──────── stealth grade - └─ growth ───────── memory growth (scheduled only) + └─ growth × 7 shards memory growth, one test per runner │ summary ──► one comment on the PR ``` @@ -321,8 +322,9 @@ authority for what fails; a local run is a hypothesis.** - **Memory growth.** Drive one mechanism (iframes, canvas readback, WebGL contexts, workers, script compilation, font measurement) N and 4N times and compare the growth: a bounded cost stays flat, a per-iteration leak scales - (`test_memory_growth.py`). It takes over half an hour, so it runs on the - schedule and on demand (the `growth` job, `--subset growth`), not in the gate. + (`test_memory_growth.py`). It takes over half an hour in one process, so CI + runs it one test per runner (the `growth` job, `--subset growth --shard i/7`) + on every pull request, in the gate. - **Settled decisions.** `ci/tribal-rules.yml` lists choices this project already made, each with the issue or PR that made it, and `native-tests/test_tribal_rules.py` asserts them. A comment explaining a @@ -544,9 +546,11 @@ Each tier gates the next, so a two-second lint failure never reaches the build: 1 unit pythonlib, typescript ~1 min 2 browser build (patches/additions/settings/assets/upstream.sh/Makefile/scripts changed) fetch (anything else, when the published release has this tree's browser sources) -3a smoke patch guards, skiplist audit, build-tester, +3a smoke patch guards (spoofing, automation, parity), + skiplist audit, build-tester, typescript-browser ~15 min -3b full Playwright x6, leaks, stealth ~40 min +3b full Playwright x6, leaks, memory growth x7, + stealth ~40 min 4 gate the required check ``` @@ -642,6 +646,7 @@ python3 -m ci.run_playwright --binary path/to/camoufox-bin --shard 3/6 python3 -m ci.run_native --subset rules # no browser needed python3 -m ci.run_native --subset browser --binary path/to/camoufox-bin python3 -m ci.run_native --subset growth --binary path/to/camoufox-bin +python3 -m ci.run_native --subset growth --binary path/to/camoufox-bin --shard 3/7 python3 -m ci.run_sundial --binary path/to/camoufox-bin python3 -m ci.summarize --results-dir .ci-work/results ``` diff --git a/ci/run_native.py b/ci/run_native.py index a37a4bff5..662c82ca7 100644 --- a/ci/run_native.py +++ b/ci/run_native.py @@ -24,11 +24,11 @@ import os import sys from pathlib import Path -from typing import List, Optional +from typing import Dict, List, Optional, Tuple from . import results from ._pytest import parse_junit, run_pytest -from ._util import REPO_ROOT, RESULTS_DIR, WORK_DIR +from ._util import REPO_ROOT, RESULTS_DIR, WORK_DIR, run SUITE_DIR = REPO_ROOT / "native-tests" @@ -40,12 +40,31 @@ "test_crash_recovery.py", ], # Slow by construction: each mechanism is churned twice, at n and 4n, to - # measure whether growth scales with the count. Kept out of "browser" so a - # pull request is not waiting on it, and run on its own schedule. + # measure whether growth scales with the count -- ~38 minutes in one + # process. Kept out of "browser" and run with --shard, one test per + # runner, so a pull request waits minutes for it rather than most of an hour. "growth": ["test_memory_growth.py"], } +def parse_shard(text: str) -> Tuple[int, int]: + """'3/7' -> (3, 7), refusing anything that would silently run nothing.""" + index, _, count = text.partition("/") + i, n = int(index), int(count) + if not 1 <= i <= n: + raise SystemExit(f"--shard {text}: want i/n with 1 <= i <= n") + return i, n + + +def collect(files: List[str], env: Dict[str, str]) -> List[str]: + """The node ids pytest would run for `files`, in its order.""" + proc = run( + [sys.executable, "-m", "pytest", "--collect-only", "-q", "-p", "no:cacheprovider", *files], + cwd=SUITE_DIR, env=env, timeout=300, + ) + return [line.strip() for line in proc.stdout.splitlines() if "::" in line] + + def main(argv: Optional[List[str]] = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( @@ -56,9 +75,14 @@ def main(argv: Optional[List[str]] = None) -> int: parser.add_argument("--rounds", type=int, default=3, help="launch/close rounds for leak tests") parser.add_argument("--browsers", type=int, default=3, help="concurrent browsers to launch") parser.add_argument("--timeout", type=int, default=3600) + parser.add_argument("--shard", help="run every n-th collected test, e.g. 3/7") args = parser.parse_args(argv) name = "native" if args.subset == "all" else f"native_{args.subset}" + shard = parse_shard(args.shard) if args.shard else None + if shard: + # summarize.py folds -of back into . + name = f"{name}-{shard[0]}of{shard[1]}" result = results.GateResult(gate=name) result.metrics["subset"] = args.subset @@ -91,6 +115,19 @@ def main(argv: Optional[List[str]] = None) -> int: env["CAMOUFOX_EXECUTABLE_PATH"] = str(binary.resolve()) result.metrics["binary"] = str(binary) + if shard: + ids = collect(files, env) + if not ids: + result.note("collected no tests; the suite did not run") + result.finish(results.ERROR).save(args.results_dir) + return 1 + files = ids[shard[0] - 1 :: shard[1]] + result.metrics["shard"] = f"{shard[0]}/{shard[1]}" + if not files: + result.note(f"shard {shard[0]}/{shard[1]} has no tests of the {len(ids)} collected") + result.finish(results.PASS).save(args.results_dir) + return 0 + junit = WORK_DIR / f"junit-{name}.xml" proc = run_pytest( cwd=SUITE_DIR, diff --git a/ci/run_patch_guards.py b/ci/run_patch_guards.py index 60a195bc7..23db40584 100644 --- a/ci/run_patch_guards.py +++ b/ci/run_patch_guards.py @@ -1,16 +1,24 @@ #!/usr/bin/env python3 -"""Patch-guard gate: tests/patches/*.py, one standalone guard per shipped behaviour. +"""Patch-guard gates: tests/patches/*.py, one standalone guard per shipped behaviour. -These are the assertions that each spoofing patch still does what it claims -- -isolated evaluate, trusted events, font spoofing, mouse trajectories and so on. -They are the most direct evidence that a Firefox bump did not quietly neuter a -patch that still applies cleanly, which is the failure mode a compile check -cannot catch. +Whether the patches *apply* is the build's job (scripts/patch.py fails it). +These check that what they do still works in the built browser -- the most +direct evidence that a Firefox bump did not quietly neuter a patch that still +applies cleanly, which is the failure mode a compile check cannot catch. + +The guards fall into three groups, each its own suite and CI job: + + spoofing a spoofed value still reaches the page and holds together + automation Playwright stays invisible to the page and never deadlocks it + parity what a page, or the OS, can observe matches stock Firefox Each guard is a standalone script exiting 0 or 1. Policy allows zero failures. +Every guard belongs to exactly one group (ci/tests checks this), so a new one +cannot be left out of CI. Run: python3 -m ci.run_patch_guards --binary /path/to/camoufox-bin + python3 -m ci.run_patch_guards --binary /path/to/camoufox-bin --group automation """ from __future__ import annotations @@ -19,7 +27,7 @@ import os import sys from pathlib import Path -from typing import List, Optional +from typing import Dict, List, Optional, Tuple from . import results as evidence from ._util import EVIDENCE_DIR, REPO_ROOT, log, run @@ -27,6 +35,50 @@ GUARD_DIR = REPO_ROOT / "tests" / "patches" +GROUPS: Dict[str, Tuple[str, ...]] = { + "spoofing": ( + "animation-timing", + "fingerprint-setter-seal", + "media-devices-coherence", + "spoofed-voice-speaks", + "startup-prefs", + "system-ui-font-spoofing", + "touchscreen-digitizer", + "worker-config-reads", + ), + "automation": ( + "addons-install-once", + "force-scope-access", + "humanize-edge-deadlock", + "humanize-mouse-trajectory", + "input-ack-backstop", + "isolated-evaluate", + "main-world-eval", + "main-world-init-script", + "mouse-boundary-sweep", + "near-edge-mouse-deadlock", + "noop-mousemove-deadlock", + "trusted-events", + "visible-automation-cues", + ), + "parity": ( + "contentaccessible-parity", + "gfx-probes-packaged", + "hardware-acceleration-policy", + "popup-blocker-parity", + "search-service-init", + "stock-parity-probes", + "viewport-no-rdm", + "windows-exe-manifest", + ), +} + + +def gate_name(group: Optional[str]) -> str: + """patch_guards for the whole set, patch_guards_ for one group.""" + return f"patch_guards_{group}" if group else "patch_guards" + + def guards() -> List[Path]: """Every guard script. helpers.py is a library, not a guard.""" return sorted(p for p in GUARD_DIR.glob("*.py") if p.name != "helpers.py") @@ -38,11 +90,12 @@ def main(argv: Optional[List[str]] = None) -> int: parser.add_argument("--evidence-dir", type=Path, default=EVIDENCE_DIR) parser.add_argument("--timeout", type=int, default=600, help="per guard") parser.add_argument("--only", nargs="*", help="run only these guard names") + parser.add_argument("--group", choices=sorted(GROUPS), help="run one group (default: all)") args = parser.parse_args(argv) from ._pytest import built_binary - result = evidence.GateResult(gate="patch_guards") + result = evidence.GateResult(gate=gate_name(args.group)) binary = args.binary or built_binary() if not binary.exists(): result.note(f"no built binary at {binary}") @@ -58,7 +111,11 @@ def main(argv: Optional[List[str]] = None) -> int: ), } - selected = [g for g in guards() if not args.only or g.stem in args.only] + selected = [ + g for g in guards() + if (not args.only or g.stem in args.only) + and (not args.group or g.stem in GROUPS[args.group]) + ] if not selected: result.note("no guards found -- tests/patches/ is empty or the filter matched nothing") result.finish(evidence.ERROR).save(args.evidence_dir) diff --git a/ci/summarize.py b/ci/summarize.py index 2e3a68a8a..39b8bdfe3 100644 --- a/ci/summarize.py +++ b/ci/summarize.py @@ -53,7 +53,9 @@ # Presented in this order; anything unexpected is appended. _ORDER = [ "native_rules", "pythonlib", "typescript", "patches_apply", "build", "patch_guards", - "skiplist_audit", "native_browser", "build_tester", "playwright", "typescript_browser", "sundial", + "patch_guards_spoofing", "patch_guards_automation", "patch_guards_parity", + "skiplist_audit", "native_browser", "build_tester", "playwright", "typescript_browser", + "native_growth", "sundial", ] diff --git a/ci/tests/test_ci.py b/ci/tests/test_ci.py index 7e7fcf67b..a8304c655 100644 --- a/ci/tests/test_ci.py +++ b/ci/tests/test_ci.py @@ -951,6 +951,7 @@ def test_required_suites_are_names_a_runner_actually_writes(): producible = { "build", "build_tester", "patch_guards", "pythonlib", "sundial", + "patch_guards_spoofing", "patch_guards_automation", "patch_guards_parity", "native", "native_rules", "native_browser", "native_growth", "playwright", "skiplist_audit", "typescript", "typescript_browser", } @@ -1818,8 +1819,9 @@ def test_the_browser_suites_are_required_either_way(): for changed in ("true", "false"): required = _required_suites(changed, "false") assert { - "pythonlib", "native_rules", "patch_guards", "skiplist_audit", - "build_tester", "playwright", "native_browser", + "pythonlib", "native_rules", "skiplist_audit", "build_tester", + "playwright", "native_browser", "native_growth", + "patch_guards_spoofing", "patch_guards_automation", "patch_guards_parity", } <= required, changed @@ -2709,3 +2711,62 @@ def test_unpublished_release_tag_builds(release_repo): (release_repo / "typescript" / "x.ts").write_text("y\n") _git(release_repo, "commit", "-qam", "driver change") assert _scope(release_repo, base)[0] == "browser_changed=true" + + +# --------------------------------------------------------------------------- +# patch guards: every guard runs in exactly one CI leg +# --------------------------------------------------------------------------- + + +def _jobs() -> dict: + import yaml + + return yaml.safe_load(WORKFLOW.read_text(encoding="utf-8"))["jobs"] + + +def test_every_patch_guard_is_in_exactly_one_group(): + """A guard in no group would never run in CI; one in two would run twice.""" + from ci.run_patch_guards import GROUPS, guards + + on_disk = {g.stem for g in guards()} + assigned = [name for names in GROUPS.values() for name in names] + assert not on_disk - set(assigned), ( + f"guards in no group, so CI never runs them: {sorted(on_disk - set(assigned))}. " + "Add each to a group in ci/run_patch_guards.py." + ) + assert not set(assigned) - on_disk, f"grouped but missing: {sorted(set(assigned) - on_disk)}" + doubled = sorted({n for n in assigned if assigned.count(n) > 1}) + assert not doubled, f"guards in more than one group: {doubled}" + + +def test_the_patch_guard_matrix_runs_every_group(): + from ci.run_patch_guards import GROUPS + + legs = {leg["leg"] for leg in _jobs()["patch-guards"]["strategy"]["matrix"]["include"]} + assert legs == set(GROUPS) | {"skiplist"} + + +# --------------------------------------------------------------------------- +# memory growth: sharded onto pull requests, and gated +# --------------------------------------------------------------------------- + + +def test_native_shards_split_the_tests_without_overlap(): + from ci.run_native import parse_shard + + ids = [f"t{i}" for i in range(9)] + parts = [ids[i - 1 :: n] for i, n in (parse_shard(f"{k}/4") for k in range(1, 5))] + assert sorted(t for part in parts for t in part) == ids + with pytest.raises(SystemExit): + parse_shard("5/4") + + +def test_memory_growth_runs_on_pull_requests_and_gates_the_merge(): + jobs = _jobs() + growth = jobs["growth"] + assert "event_name" not in str(growth.get("if", "")), "growth is limited to some events" + shards = growth["strategy"]["matrix"]["shard"] + n = len(shards) + assert shards == [f"{i}/{n}" for i in range(1, n + 1)] + assert "growth" in jobs["gate"]["needs"] + assert "growth" in jobs["summary"]["needs"] diff --git a/tests/README.md b/tests/README.md index 95a4f38fa..a86365577 100644 --- a/tests/README.md +++ b/tests/README.md @@ -4,15 +4,24 @@ Two things live here, and neither is a copy of anyone else's suite. ### `patches/` — patch guards -One standalone script per shipped spoofing behaviour: isolated evaluate, trusted -events, font spoofing, mouse trajectories, the touchscreen digitizer, and so on. -Each exits 0 or 1 and drives the browser through the Python package. +One standalone script per shipped behaviour. Each exits 0 or 1 and drives the +browser through the Python package. Whether the patches *apply* is the build's +check; these are the most direct evidence that a Firefox bump did not quietly +neuter a patch that still applies cleanly — the failure a compile check cannot +catch. -These are the most direct evidence that a Firefox bump did not quietly neuter a -patch that still *applies* cleanly — the failure a compile check cannot catch. +Each guard belongs to one of three groups (`GROUPS` in +`ci/run_patch_guards.py`), and CI runs each group as its own job: + +- **spoofing**: a spoofed value still reaches the page and holds together (media devices, voices, fonts, the touchscreen digitizer, the sealed setters, …); +- **automation**: Playwright stays invisible to the page and never deadlocks it (isolated evaluate, trusted events, humanized and edge-case mouse input, …); +- **parity**: what a page or the OS can observe matches stock Firefox (content-accessible files, GPU probes, the popup blocker, the Windows manifest, …). + +A new guard must be added to a group; `ci/tests` fails until it is. ```bash python3 -m ci.run_patch_guards --binary /path/to/camoufox-bin +python3 -m ci.run_patch_guards --binary /path/to/camoufox-bin --group automation python3 -m ci.run_patch_guards --binary /path/to/camoufox-bin --only isolated-evaluate ``` diff --git a/tests/patches/contentaccessible-parity.py b/tests/patches/contentaccessible-parity.py index df38389a4..887157e4f 100644 --- a/tests/patches/contentaccessible-parity.py +++ b/tests/patches/contentaccessible-parity.py @@ -151,6 +151,9 @@ def static_check(binary: Path, manifest: dict, failures: list) -> None: print(f" ok {key.split('|', 2)[2]:32} {len(built):>5} files") +PROBE_MARK = "PROBE-RESULT " + + def run_probe(binary: Path) -> dict: """The probe itself, run in a child process (see live_check).""" from camoufox.sync_api import Camoufox @@ -181,7 +184,14 @@ def live_check(binary: Path, failures: list) -> None: if proc.returncode != 0: failures.append(f"live probe failed: {proc.stderr.strip().splitlines()[-1:] or proc.stdout}") return - results = json.loads(proc.stdout) + # The launcher may print to stdout first -- on a fresh machine the first + # launch downloads the default addons and reports progress -- so read the + # marked result line, not the whole stream. + marked = [line for line in proc.stdout.splitlines() if line.startswith(PROBE_MARK)] + if not marked: + failures.append(f"live probe printed no result line: {proc.stdout.strip()[-300:]!r}") + return + results = json.loads(marked[-1][len(PROBE_MARK):]) for url, must_load in LIVE_PROBES: got = results.get(url) @@ -227,6 +237,6 @@ def main() -> int: if __name__ == "__main__": if len(sys.argv) == 3 and sys.argv[1] == "--probe": - print(json.dumps(run_probe(Path(sys.argv[2])))) + print(PROBE_MARK + json.dumps(run_probe(Path(sys.argv[2])))) sys.exit(0) sys.exit(main()) diff --git a/tests/patches/gfx-probes-packaged.py b/tests/patches/gfx-probes-packaged.py index 687479785..6ed69e0df 100644 --- a/tests/patches/gfx-probes-packaged.py +++ b/tests/patches/gfx-probes-packaged.py @@ -40,6 +40,7 @@ REPO_ROOT = Path(__file__).resolve().parents[2] PROBES = ("glxtest", "vaapitest") LOAD_TIMEOUT_S = 90 +ATTEMPTS = 2 PAGE = b"""gfx-probes