diff --git a/pythonlib/camoufox/fingerprints.py b/pythonlib/camoufox/fingerprints.py index 8cddf50..2cad109 100644 --- a/pythonlib/camoufox/fingerprints.py +++ b/pythonlib/camoufox/fingerprints.py @@ -406,6 +406,52 @@ def clamp_window_dimensions(config: Dict[str, Any]) -> None: config[f'window.inner{axis}'] = outer_clamped +def clamp_screen_to_display( + config: Dict[str, Any], + max_width: Optional[int], + max_height: Optional[int], +) -> None: + """Shrink screen.width/height down to the bounds of the real display. + + BrowserForge takes a Screen constraint but drops it silently whenever it + filters the fingerprint pool too far: FingerprintGenerator.partial_csp + swallows the resulting failure and deletes the constraint unless strict=True. + So the bound from get_screen_cons() is best-effort only, and a 1366x768 + laptop routinely gets a 2560x1440 fingerprint. browser-init.patch resizes the + real chrome window to window.outerWidth/outerHeight, so an unbounded value + renders past the edge of the monitor (daijro/camoufox#499). + + Keeps the taskbar delta (screen - avail) intact so fix_screen_no_taskbar's + invariant survives. Callers must run clamp_window_dimensions afterwards to + cascade the new bounds down to avail/outer/inner. + """ + for axis, cap in (('width', max_width), ('height', max_height)): + screen = config.get(f'screen.{axis}') + if not (screen and cap) or screen <= cap: + continue + avail_key = 'screen.availWidth' if axis == 'width' else 'screen.availHeight' + avail = config.get(avail_key) + config[f'screen.{axis}'] = cap + if avail: + config[avail_key] = max(1, cap - max(0, screen - avail)) + + +def clamp_window_position(config: Dict[str, Any]) -> None: + """Keep the window box inside the screen: 0 <= screenX/Y <= screen - outer. + + BrowserForge's screenX/screenY are consistent with the screen it generated + them against, so clamp_screen_to_display invalidates them. A window + positioned partly off its own reported screen is an impossible geometry. + """ + for axis, pos_key in (('Width', 'window.screenX'), ('Height', 'window.screenY')): + screen = config.get(f'screen.{axis.lower()}') + outer = config.get(f'window.outer{axis}') + pos = config.get(pos_key) + if pos is None or not (screen and outer): + continue + config[pos_key] = max(0, min(pos, screen - outer)) + + def set_media_devices_defaults(config: Dict[str, Any]) -> None: """Spoof navigator.mediaDevices.enumerateDevices() so headless contexts expose a plausible device list. diff --git a/pythonlib/camoufox/utils.py b/pythonlib/camoufox/utils.py index e5776c8..795a90d 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_window_dimensions, 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, 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 @@ -679,10 +679,14 @@ def launch_options( merge_into(config, from_preset(preset, ff_version_str)) _used_preset = True + # Bound the geometry to the real display. BrowserForge only honours this when + # its pool has a match, so it is re-applied after generation as well. + screen_cons = screen or get_screen_cons(headless or 'DISPLAY' in env) + if not _used_preset and fingerprint is None: # Default: BrowserForge synthetic generation (infinite unique fingerprints) fingerprint = generate_fingerprint( - screen=screen or get_screen_cons(headless or 'DISPLAY' in env), + screen=screen_cons, window=window, os=os, ) @@ -701,8 +705,15 @@ def launch_options( if not _user_set_navigator: fix_navigator_arch(config, target_os) if not _user_set_screen_window: + # Headful only: this bound exists so the real window fits the monitor. + # headless has no window to overflow, and headless='virtual' runs a 1x1 + # Xvfb (see virtdisplay.py) that would otherwise shrink the whole + # fingerprint to 1x1. + if headless is False and screen_cons: + clamp_screen_to_display(config, screen_cons.max_width, screen_cons.max_height) fix_screen_no_taskbar(config, target_os) clamp_window_dimensions(config) + clamp_window_position(config) # Set a random window.history.length set_into(config, 'window.history.length', randrange(1, 6)) # nosec diff --git a/pythonlib/tests/test_fingerprint_fixes.py b/pythonlib/tests/test_fingerprint_fixes.py index 9b0a75d..715382f 100644 --- a/pythonlib/tests/test_fingerprint_fixes.py +++ b/pythonlib/tests/test_fingerprint_fixes.py @@ -16,7 +16,9 @@ import sys sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from camoufox.fingerprints import ( # noqa: E402 + clamp_screen_to_display, clamp_window_dimensions, + clamp_window_position, fix_navigator_arch, fix_screen_no_taskbar, set_media_devices_defaults, @@ -128,6 +130,99 @@ class TestClampWindowDimensions: assert c["window.innerWidth"] == 1264 +class TestClampScreenToDisplay: + def test_shrinks_screen_to_display(self): + # BrowserForge routinely ships a 2560x1440 fingerprint to a 1366x768 + # laptop; browser-init then resizes the real window past the monitor. + c = { + "screen.width": 2560, + "screen.height": 1440, + "screen.availWidth": 2560, + "screen.availHeight": 1400, + } + clamp_screen_to_display(c, 1366, 768) + assert c["screen.width"] == 1366 + assert c["screen.height"] == 768 + # taskbar delta (1440-1400=40) preserved, so fix_screen_no_taskbar's + # avail < height invariant still holds + assert c["screen.availWidth"] == 1366 + assert c["screen.availHeight"] == 768 - 40 + + def test_noop_when_already_within_display(self): + c = {"screen.width": 1280, "screen.height": 720, "screen.availHeight": 700} + clamp_screen_to_display(c, 1366, 768) + assert c["screen.width"] == 1280 + assert c["screen.height"] == 720 + assert c["screen.availHeight"] == 700 + + def test_ignores_unset_bounds(self): + c = {"screen.width": 2560, "screen.height": 1440} + clamp_screen_to_display(c, None, None) + assert c["screen.width"] == 2560 + assert c["screen.height"] == 1440 + + def test_avail_never_drops_below_one(self): + # taskbar delta larger than the display must not produce a <= 0 avail + c = {"screen.height": 2000, "screen.availHeight": 100} + clamp_screen_to_display(c, None, 768) + assert c["screen.availHeight"] >= 1 + + def test_clamped_result_survives_cascade(self): + c = { + "screen.width": 2560, + "screen.height": 1440, + "screen.availWidth": 2560, + "screen.availHeight": 1400, + "window.outerWidth": 1920, + "window.outerHeight": 1055, + "window.innerWidth": 1920, + "window.innerHeight": 1000, + } + clamp_screen_to_display(c, 1366, 768) + clamp_window_dimensions(c) + assert c["window.outerWidth"] <= c["screen.availWidth"] <= c["screen.width"] == 1366 + assert c["window.outerHeight"] <= c["screen.availHeight"] <= c["screen.height"] == 768 + assert c["window.innerWidth"] <= c["window.outerWidth"] + assert c["window.innerHeight"] <= c["window.outerHeight"] + + +class TestClampWindowPosition: + def test_pulls_window_back_inside_screen(self): + c = { + "screen.width": 1366, + "screen.height": 768, + "window.outerWidth": 1366, + "window.outerHeight": 728, + "window.screenX": 250, + "window.screenY": 281, + } + clamp_window_position(c) + assert c["window.screenX"] == 0 + assert c["window.screenY"] == 40 + + def test_noop_when_window_already_inside(self): + c = { + "screen.width": 1920, + "screen.height": 1080, + "window.outerWidth": 1280, + "window.outerHeight": 720, + "window.screenX": 100, + "window.screenY": 50, + } + clamp_window_position(c) + assert c["window.screenX"] == 100 + assert c["window.screenY"] == 50 + + def test_never_negative(self): + c = { + "screen.width": 800, + "window.outerWidth": 1000, # wider than screen + "window.screenX": 50, + } + clamp_window_position(c) + assert c["window.screenX"] == 0 + + class TestSetMediaDevicesDefaults: def test_sets_one_mic_one_cam(self): c = {}