diff --git a/pythonlib/camoufox/fingerprints.py b/pythonlib/camoufox/fingerprints.py index 2cad109..ccb3866 100644 --- a/pythonlib/camoufox/fingerprints.py +++ b/pythonlib/camoufox/fingerprints.py @@ -4,7 +4,7 @@ import re from dataclasses import asdict, dataclass from pathlib import Path from random import choice, randint, randrange, random, sample, shuffle -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, FrozenSet, List, Optional, Tuple from browserforge.fingerprints import ( Fingerprint, @@ -470,6 +470,201 @@ def set_media_devices_defaults(config: Dict[str, Any]) -> None: config['mediaDevices:speakers'] = 0 +# -- WebGL <-> screen coherence (#729) --------------------------------------- +# +# BrowserForge picks navigator/screen; the GPU is drawn separately from +# webgl_data.db weighted only by OS. Nothing ties the two together, so the +# synthetic path can emit pairs no real machine ships -- a discrete GPU behind +# a 1024x600 netbook panel. Consistency checks (Pixelscan, Fingerprint.com) +# read that as masking even when every individual value is plausible alone. +# +# What can honestly be claimed here is narrow, because Firefox never reports +# the GPU it actually sees. dom/canvas/SanitizeRenderer.cpp collapses every +# renderer string into one of ~11 representative device buckets before a page +# sees it (prefs webgl.sanitize-unmasked-renderer and +# webgl.enable-renderer-query, both default true; resistFingerprinting +# replaces the value with "Mozilla" outright). That file's own header comment +# gives the flavour: `"GeForce RTX 3090" => "GeForce GTX 980"`. So every RTX, +# every Quadro M/P/V/T and every GeForce 900-7999 arrive as one string, while +# "Intel(R) UHD Graphics 620" and "Mesa Intel(R) Iris(R) Xe Graphics" both +# arrive as an "Intel(R) HD Graphics" spelling. +# +# Two consequences. Matching on raw model names -- RTX, Quadro, RX, UHD, Iris, +# Mesa Intel -- can never fire, because those are exactly the strings Gecko +# collapses away. And a bucket spanning a desktop RTX 4090 and a mobile GTX +# 1650 Max-Q carries no useful screen floor: 1366x768 laptops with discrete +# NVIDIA GPUs are ordinary hardware, not a tell. +# +# So the rule below holds only what is true of *every* part behind a bucket, +# and renderers are reduced to their bucket first (see _renderer_bucket) so +# the ANGLE, nouveau and /PCIe/SSE2 spellings of one GPU land on one rule +# instead of three different ones. + +# Software rasterizers. A VM or headless host reports whatever resolution the +# window manager hands it, so no screen constrains them -- and the sampler +# must never come to *prefer* them, because a software renderer is a far +# stronger "this is a bot" signal than any GPU/screen mismatch. +_SOFTWARE_RENDERERS: Tuple[str, ...] = ( + 'llvmpipe', + 'Microsoft Basic Render Driver', + 'SwiftShader', + 'Generic Renderer', +) + +# Discrete NVIDIA, plus the AMD R5/R7/R9/RX/Vega bucket. Everything else in +# webgl_data.db reaches down into netbook territory and gets no floor at all: +# the "Intel(R) HD Graphics" bucket swallows the GMA 3150 netbook chipset, +# "Radeon HD 3200 Graphics" is Gecko's catch-all for a bare "AMD"/"Radeon" +# (the C-50/E-350 netbook APUs included), and Apple silicon drives arbitrary +# external monitors from a Mac mini or Mac Studio. +_DISCRETE_GPU_BUCKETS: FrozenSet[str] = frozenset( + { + 'GeForce 8800 GTX', + 'GeForce GTX 480', + 'GeForce GTX 980', + 'Radeon R9 200 Series', + } +) + +# Discrete GPUs did not ship in netbooks, and netbook panels topped out at +# 1024x600. That is the whole of the claim. +# +# It is an area rather than a width x height pair because real panels do not +# dominate one another: 1280x800 and 1366x768 are both ordinary laptop +# screens, and a per-axis floor taken from either one rejects the other. A +# 1366x768 laptop with a discrete GPU is common hardware, not a tell. +_NETBOOK_MAX_PIXELS = 1024 * 600 + +# The three shapes SanitizeRenderer wraps a device bucket in. +_ANGLE_D3D_RE = re.compile(r'^ANGLE \([^,]*, (.*?) Direct3D.*\)$') +_ANGLE_VULKAN_RE = re.compile(r'^ANGLE \((.*)\) on Vulkan$') +_PCIE_SSE2_RE = re.compile(r'^(.*)/PCIe?/SSE2$') + + +def _renderer_bucket(renderer: str) -> str: + """Reduce a reported renderer to Gecko's sanitized device bucket. + + "ANGLE (NVIDIA, NVIDIA GeForce GTX 980 Direct3D11 vs_5_0 ps_5_0), or + similar" (Windows), "NVIDIA GeForce GTX 980/PCIe/SSE2" (Linux proprietary + driver) and "GeForce GTX 980, or similar" (nouveau, which loses the vendor + prefix) are one GPU class in three spellings. Without this they land on + three different rules, or none. + """ + core = renderer.removesuffix(', or similar') + match = _ANGLE_D3D_RE.match(core) or _ANGLE_VULKAN_RE.match(core) + if match: + core = match.group(1) + match = _PCIE_SSE2_RE.match(core) + if match: + core = match.group(1) + # SanitizeRenderer re-adds the "NVIDIA " prefix only when the raw string + # carried it, so one bucket arrives both with and without it. + return core.removeprefix('NVIDIA ') + + +# The smallest screen mainstream hardware still ships. BrowserForge's pool +# carries netbook-era geometry that essentially no 2026 device reports, and +# that is a tell on its own, whatever GPU sits behind it. +MODERN_SCREEN_FLOOR: Tuple[int, int] = (1366, 768) + + +def raise_screen_to_modern_floor(config: Dict[str, Any]) -> None: + """Lift netbook-era screen geometry to something current hardware reports. + + BrowserForge still draws 1024x600 and friends. Those panels left + production a decade and a half ago, so the screen is what has to move -- + no GPU choice makes that profile look current. + + Keeps the screen-to-avail gap intact so fix_screen_no_taskbar's invariant + survives; the window box is reconciled by clamp_window_dimensions and + clamp_window_position, which run after this. Call BEFORE + clamp_screen_to_display so a genuinely small real monitor still wins. + """ + min_w, min_h = MODERN_SCREEN_FLOOR + sw = config.get('screen.width') + sh = config.get('screen.height') + if not (sw and sh) or (sw >= min_w and sh >= min_h): + return + + # Measure the gaps before mutating, or they get folded into themselves. + aw = config.get('screen.availWidth') + ah = config.get('screen.availHeight') + gap_w = sw - aw if aw else None + gap_h = sh - ah if ah else None + + new_w, new_h = max(sw, min_w), max(sh, min_h) + config['screen.width'] = new_w + config['screen.height'] = new_h + if gap_w is not None: + config['screen.availWidth'] = max(1, new_w - max(0, gap_w)) + if gap_h is not None: + config['screen.availHeight'] = max(1, new_h - max(0, gap_h)) + + +def is_software_renderer(renderer: Optional[str]) -> bool: + """Whether `renderer` is a software rasterizer rather than real hardware.""" + return bool(renderer) and any(name in renderer for name in _SOFTWARE_RENDERERS) + + +def gpu_screen_is_plausible( + renderer: Optional[str], width: Optional[int], height: Optional[int] +) -> bool: + """Whether `renderer` is a GPU that plausibly drives a `width` x `height` screen. + + Unconstrained buckets and software rasterizers pass. The set only names + buckets whose floor holds for every part behind them, so anything absent + from it is genuinely unconstrained rather than merely unrecognised. + """ + if not renderer or not width or not height: + return True + if is_software_renderer(renderer): + return True + if _renderer_bucket(renderer) not in _DISCRETE_GPU_BUCKETS: + return True + return width * height > _NETBOOK_MAX_PIXELS + + +def sample_webgl_for_screen( + target_os: str, + width: Optional[int] = None, + height: Optional[int] = None, + attempts: int = 32, +) -> Dict[str, str]: + """Sample a WebGL profile that is coherent with the screen already chosen. + + Rejection sampling, so the GPU keeps webgl_data.db's real OS-weighted + distribution -- we only drop draws that contradict the screen. The screen + itself is left alone on purpose: it has already been reconciled with the + real display and the window box (clamp_screen_to_display, + fix_screen_no_taskbar, clamp_window_dimensions, clamp_window_position), + and widening it here to flatter the GPU would push a headful window back + off the monitor it is drawn on (#499). + + The first draw settles hardware-vs-software at the pool's natural rate and + is never resampled once it lands on a rasterizer. Rejecting only hardware + draws would renormalise the survivors onto llvmpipe / WARP / SwiftShader: + on a small screen that turns a 1.5% software rate into a 40% one, trading + a weak incoherence for the strongest VM/headless tell there is. + + Falls back to that first draw when the pool holds nothing coherent, so an + unusual screen degrades to today's behaviour rather than raising. + """ + first = sample_webgl(target_os) + renderer = first.get('webGl:renderer') + if is_software_renderer(renderer) or gpu_screen_is_plausible(renderer, width, height): + return first + + for _ in range(attempts - 1): + candidate = sample_webgl(target_os) + renderer = candidate.get('webGl:renderer') + # Skip rather than accept: the class was settled by the first draw. + if is_software_renderer(renderer): + continue + if gpu_screen_is_plausible(renderer, width, height): + return candidate + return first + + def _select_presets_file(ff_version: Optional[Any] = None) -> Path: """Pick the bundled-presets file appropriate for a given Firefox version. @@ -812,7 +1007,14 @@ def generate_context_fingerprint( else: _target_os = 'mac' try: - webgl_fp = sample_webgl(_target_os) + # Same coherence treatment launch_options applies (#729): lift + # netbook geometry, then keep the GPU consistent with whatever + # screen this identity ended up with. This path has no real + # 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') + ) webgl_fp.pop('webGl2Enabled', None) config.update(webgl_fp) except Exception: diff --git a/pythonlib/camoufox/utils.py b/pythonlib/camoufox/utils.py index f923250..6779a0b 100644 --- a/pythonlib/camoufox/utils.py +++ b/pythonlib/camoufox/utils.py @@ -21,7 +21,7 @@ from .exceptions import ( InvalidPropertyType, NonFirefoxFingerprint, ) -from .fingerprints import from_browserforge, from_preset, generate_fingerprint, get_random_preset, _generate_random_font_subset, _generate_random_voice_subset, fix_navigator_arch, fix_screen_no_taskbar, clamp_screen_to_display, clamp_window_dimensions, clamp_window_position, set_media_devices_defaults +from .fingerprints import from_browserforge, from_preset, generate_fingerprint, get_random_preset, _generate_random_font_subset, _generate_random_voice_subset, fix_navigator_arch, fix_screen_no_taskbar, clamp_screen_to_display, clamp_window_dimensions, clamp_window_position, raise_screen_to_modern_floor, sample_webgl_for_screen, set_media_devices_defaults from .geolocation import geoip_allowed, get_geolocation from .ip import Proxy, public_ip, valid_ipv4, valid_ipv6 from .locales import handle_locales @@ -741,6 +741,16 @@ def launch_options( if not _user_set_navigator: fix_navigator_arch(config, target_os) if not _user_set_screen_window: + # Lift netbook-era geometry to something current hardware reports, + # before the display clamp below so a genuinely small real monitor + # still wins (#729). Synthetic draws only: a preset is a real device, + # internally consistent by construction, and two of the bundled v150 + # presets genuinely report sub-netbook screens (736x414, 960x540). + # Rewriting those to 1366x768 would break the very coherence #729 is + # about, and _user_set_screen_window is read before the preset merges + # in, so it does not cover this. + if not _used_preset: + raise_screen_to_modern_floor(config) # Headful on a real monitor only: this bound exists so the window fits # the screen it is drawn on. headless has no window to overflow, and # headless='virtual' reaches here as headless=False (see async_api) with @@ -897,7 +907,13 @@ def launch_options( # Preset already set vendor/renderer — sample matching WebGL params webgl_fp = sample_webgl(target_os, config['webGl:vendor'], config['webGl:renderer']) else: - webgl_fp = sample_webgl(target_os) + # Synthetic path: keep the GPU coherent with the screen BrowserForge + # already picked. Sampling the two independently yields pairs no + # real machine ships -- a discrete desktop GPU behind a 1024x600 + # panel -- which consistency checks read as masking (#729). + webgl_fp = sample_webgl_for_screen( + target_os, config.get('screen.width'), config.get('screen.height') + ) enable_webgl2 = webgl_fp.pop('webGl2Enabled') # Merge the WebGL fingerprint into the config diff --git a/pythonlib/tests/test_webgl_screen_consistency.py b/pythonlib/tests/test_webgl_screen_consistency.py new file mode 100644 index 0000000..5cf8bd3 --- /dev/null +++ b/pythonlib/tests/test_webgl_screen_consistency.py @@ -0,0 +1,323 @@ +""" +Tests for the WebGL <-> screen coherence helpers in camoufox.fingerprints. + +Run with: + cd pythonlib && python -m pytest tests/test_webgl_screen_consistency.py -v + +The regression these guard (daijro/camoufox#729): BrowserForge picks the +screen, webgl_data.db picks the GPU, and nothing ties them together -- so the +synthetic path can emit pairs no real machine ships (a discrete GPU behind a +1024x600 netbook panel). + +The constraint has to be read through Gecko's renderer sanitizer +(dom/canvas/SanitizeRenderer.cpp), which collapses every renderer string into +one of ~11 device buckets before a page sees it. Two things follow, and most +of these tests exist to pin them down: raw model names never reach the page, +and a bucket spanning a desktop RTX 4090 and a mobile GTX 1650 Max-Q cannot +carry a 1080p floor. +""" + +import os +import sys + +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from camoufox import fingerprints # noqa: E402 +from camoufox.fingerprints import ( # noqa: E402 + MODERN_SCREEN_FLOOR, + _renderer_bucket, + gpu_screen_is_plausible, + is_software_renderer, + raise_screen_to_modern_floor, + sample_webgl_for_screen, +) + +# The three spellings Gecko emits for one discrete-NVIDIA bucket. +_NV_ANGLE = "ANGLE (NVIDIA, NVIDIA GeForce GTX 980 Direct3D11 vs_5_0 ps_5_0), or similar" +_NV_PCIE = "NVIDIA GeForce GTX 980/PCIe/SSE2" +_NV_NOUVEAU = "GeForce GTX 980, or similar" + +_AMD_IGP = "ANGLE (AMD, Radeon HD 3200 Graphics Direct3D11 vs_5_0 ps_5_0), or similar" +_INTEL = "ANGLE (Intel, Intel(R) HD Graphics Direct3D11 vs_5_0 ps_5_0), or similar" +_APPLE = "Apple M1, or similar" +_LLVMPIPE = "llvmpipe, or similar" + + +# -- bucket reduction -------------------------------------------------------- + + +@pytest.mark.parametrize("renderer", [_NV_ANGLE, _NV_PCIE, _NV_NOUVEAU]) +def test_every_spelling_of_one_gpu_reduces_to_one_bucket(renderer): + # ANGLE wrapping, the /PCIe/SSE2 suffix and nouveau's missing "NVIDIA " + # prefix are the same GPU class. Matching raw substrings put these on + # three different rules, so the same hardware got three different floors. + assert _renderer_bucket(renderer) == "GeForce GTX 980" + + +def test_angle_vendor_field_does_not_decide_the_bucket(): + # Regression: matching the substring "ANGLE (AMD" gave this integrated + # chipset a discrete-GPU floor, while its Linux spelling got none. + assert _renderer_bucket(_AMD_IGP) == "Radeon HD 3200 Graphics" + assert _renderer_bucket("Radeon HD 3200 Graphics, or similar") == ( + "Radeon HD 3200 Graphics" + ) + + +def test_angle_vulkan_form_is_unwrapped(): + assert _renderer_bucket("ANGLE (Samsung Xclipse 920) on Vulkan") == ( + "Samsung Xclipse 920" + ) + + +# -- what the table constrains ---------------------------------------------- + + +@pytest.mark.parametrize("renderer", [_NV_ANGLE, _NV_PCIE, _NV_NOUVEAU]) +def test_discrete_gpu_rejected_on_a_netbook_screen(renderer): + assert not gpu_screen_is_plausible(renderer, 1024, 600) + + +@pytest.mark.parametrize( + "width,height", [(1024, 768), (1280, 720), (1280, 800), (1366, 768), (1920, 1080)] +) +def test_discrete_gpu_accepted_on_any_real_laptop_panel(width, height): + # A 1366x768 laptop with a discrete NVIDIA GPU is ordinary hardware, and + # "GeForce GTX 980, or similar" is the bucket a mobile GTX 1650 Max-Q + # reports. Anything stricter rejects real machines. + assert gpu_screen_is_plausible(_NV_ANGLE, width, height) + + +def test_the_floor_is_an_area_not_a_per_axis_bound(): + # 1280x800 and 1366x768 are both ordinary panels and neither dominates the + # other, so a per-axis floor taken from one throws out the other. + assert gpu_screen_is_plausible(_NV_ANGLE, 1280, 800) + assert gpu_screen_is_plausible(_NV_ANGLE, 1366, 768) + + +@pytest.mark.parametrize("width,height", [(1024, 600), (800, 480), (1024, 576)]) +def test_discrete_gpu_rejected_on_netbook_panels(width, height): + assert not gpu_screen_is_plausible(_NV_ANGLE, width, height) + + +def test_integrated_parts_are_not_floored(): + # Gecko's "Intel(R) HD Graphics" bucket swallows the GMA 3150 netbook + # chipset, and "Radeon HD 3200 Graphics" is its catch-all for a bare + # "AMD"/"Radeon" -- including netbook APUs. Neither carries a floor. + assert gpu_screen_is_plausible(_INTEL, 1024, 600) + assert gpu_screen_is_plausible(_AMD_IGP, 1024, 600) + + +def test_apple_silicon_is_not_pinned_to_retina(): + # Apple silicon also ships in the Mac mini / Mac Studio, which drive + # whatever external monitor is attached. + assert gpu_screen_is_plausible(_APPLE, 1920, 1080) + assert gpu_screen_is_plausible(_APPLE, 1280, 800) + + +def test_raw_model_names_are_out_of_scope(): + # Firefox never reports these: SanitizeRenderer turns an RTX 3070 into + # "GeForce GTX 980" and a UHD 620 into "Intel(R) HD Graphics 400" before + # the page sees anything. Keying on them was matching nothing. + assert gpu_screen_is_plausible( + "ANGLE (NVIDIA, NVIDIA GeForce RTX 3070 Direct3D11 vs_5_0 ps_5_0)", 1024, 600 + ) + + +def test_missing_values_are_not_constrained(): + assert gpu_screen_is_plausible(None, 1920, 1080) + assert gpu_screen_is_plausible(_NV_ANGLE, None, None) + + +# -- software rasterizers ---------------------------------------------------- + + +@pytest.mark.parametrize( + "renderer", + [ + _LLVMPIPE, + "ANGLE (Microsoft, Microsoft Basic Render Driver Direct3D11 vs_5_0 ps_5_0)", + "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero)), SwiftShader driver)", + "Generic Renderer", + ], +) +def test_software_renderers_are_unconstrained(renderer): + # A VM reports whatever the window manager hands it. + assert is_software_renderer(renderer) + assert gpu_screen_is_plausible(renderer, 1024, 600) + + +def test_hardware_is_not_mistaken_for_software(monkeypatch): + for renderer in (_NV_ANGLE, _INTEL, _APPLE, _AMD_IGP): + assert not is_software_renderer(renderer) + + +def test_software_first_draw_is_never_resampled(monkeypatch): + """The strongest reason this sampler must not be a plain reject loop. + + Rejecting hardware draws while accepting every software one renormalises + the pool onto llvmpipe / WARP / SwiftShader. On a sub-floor screen that + turned a 1.5% software rate into ~40%, trading a weak incoherence for the + strongest VM/headless tell there is. So the first draw settles the class. + """ + draws = iter([{"webGl:renderer": _LLVMPIPE}, {"webGl:renderer": _INTEL}]) + monkeypatch.setattr(fingerprints, "sample_webgl", lambda *a, **kw: next(draws)) + + # 1024x600 would reject a discrete GPU, but llvmpipe is plausible there and + # must be returned as drawn rather than swapped for the Intel part. + assert sample_webgl_for_screen("lin", 1024, 600)["webGl:renderer"] == _LLVMPIPE + + +def test_software_draws_are_skipped_when_resampling(monkeypatch): + # A hardware first draw settles the class as hardware, so a software + # candidate mid-loop is skipped instead of accepted -- otherwise the + # rejection loop still leaks probability mass onto the rasterizers. + draws = iter( + [ + {"webGl:renderer": _NV_ANGLE}, # implausible at 1024x600 + {"webGl:renderer": _LLVMPIPE}, # plausible, but wrong class + {"webGl:renderer": _INTEL}, # the coherent hardware answer + ] + ) + monkeypatch.setattr(fingerprints, "sample_webgl", lambda *a, **kw: next(draws)) + + assert sample_webgl_for_screen("lin", 1024, 600)["webGl:renderer"] == _INTEL + + +def test_falls_back_to_the_first_draw_when_nothing_is_coherent(monkeypatch): + monkeypatch.setattr( + fingerprints, "sample_webgl", lambda *a, **kw: {"webGl:renderer": _NV_ANGLE} + ) + fp = sample_webgl_for_screen("win", 800, 600, attempts=4) + assert fp["webGl:renderer"] == _NV_ANGLE + + +def test_a_plausible_first_draw_costs_one_query(monkeypatch): + # sample_webgl opens a fresh sqlite connection per call, and the common + # case (any screen at or above the floor) must not pay for 32 of them. + calls = [] + + def _counted(*args, **kwargs): + calls.append(args) + return {"webGl:renderer": _NV_ANGLE} + + monkeypatch.setattr(fingerprints, "sample_webgl", _counted) + sample_webgl_for_screen("win", 1920, 1080) + assert len(calls) == 1 + + +@pytest.mark.parametrize("target_os", ["win", "mac", "lin"]) +def test_sampled_gpu_is_coherent_with_the_screen(target_os): + # Against the real webgl_data.db pool. + for _ in range(25): + fp = sample_webgl_for_screen(target_os, 1280, 800) + assert gpu_screen_is_plausible(fp.get("webGl:renderer"), 1280, 800) + + +# -- the screen floor -------------------------------------------------------- + + +def test_screen_floor_lifts_netbook_geometry(): + config = { + "screen.width": 1024, + "screen.height": 600, + "screen.availWidth": 1024, + "screen.availHeight": 560, + } + raise_screen_to_modern_floor(config) + assert (config["screen.width"], config["screen.height"]) == MODERN_SCREEN_FLOOR + # The taskbar gap has to survive, or fix_screen_no_taskbar's invariant -- + # and CreepJS's noTaskbar check -- breaks. + assert config["screen.height"] - config["screen.availHeight"] == 40 + assert config["screen.width"] - config["screen.availWidth"] == 0 + assert config["screen.availHeight"] < config["screen.height"] + + +def test_screen_floor_leaves_an_adequate_screen_alone(): + config = { + "screen.width": 1920, + "screen.height": 1080, + "screen.availWidth": 1920, + "screen.availHeight": 1040, + } + before = dict(config) + raise_screen_to_modern_floor(config) + assert config == before + + +def test_screen_floor_is_a_no_op_without_screen_values(): + config = {} + raise_screen_to_modern_floor(config) + assert config == {} + + +# -- the two entry points must agree ---------------------------------------- + + +@pytest.mark.parametrize("target_os", ["windows", "macos", "linux"]) +def test_context_fingerprints_get_the_same_treatment(target_os): + """generate_context_fingerprint() is the per-context API #729 names, and + build-tester drives the browser through it. It sampled the GPU with a bare + sample_webgl() and never applied the floor, so the coherence fix reached + launch_options() only.""" + from camoufox.fingerprints import generate_context_fingerprint + + for _ in range(15): + config = generate_context_fingerprint(os=target_os)["config"] + renderer = config.get("webGl:renderer") + width, height = config.get("screen.width"), config.get("screen.height") + assert renderer and width and height + assert gpu_screen_is_plausible(renderer, width, height) + assert width * height > 1024 * 600 + + +def test_preset_screens_are_never_lifted(): + """Presets are real devices, coherent by construction -- #729 says so + explicitly. Two bundled v150 presets report genuinely sub-netbook screens, + and the floor used to rewrite them to 1366x768 because + _user_set_screen_window is computed before the preset merges in.""" + import json + from pathlib import Path + + from camoufox.utils import launch_options + + presets = json.loads( + (Path(__file__).parent.parent / "camoufox" / "fingerprint-presets-v150.json").read_text() + ) + + def small_presets(node): + if isinstance(node, dict): + screen = node.get("screen") + if ( + isinstance(screen, dict) + and screen.get("width") + and screen.get("height") + and screen["width"] * screen["height"] <= 1024 * 600 + ): + yield node + for value in node.values(): + yield from small_presets(value) + elif isinstance(node, list): + for value in node: + yield from small_presets(value) + + found = list(small_presets(presets)) + assert found, "expected the v150 presets to still carry sub-netbook screens" + + for preset in found: + env = launch_options( + headless=True, fingerprint_preset=preset, i_know_what_im_doing=True + )["env"] + raw = "".join( + env[k] + for k in sorted( + (k for k in env if k.startswith("CAMOU_CONFIG_")), + key=lambda k: int(k.rsplit("_", 1)[1]), + ) + ) + config = json.loads(raw) + assert (config["screen.width"], config["screen.height"]) == ( + preset["screen"]["width"], + preset["screen"]["height"], + )