diff --git a/pythonlib/camoufox/display.py b/pythonlib/camoufox/display.py new file mode 100644 index 0000000..a4243af --- /dev/null +++ b/pythonlib/camoufox/display.py @@ -0,0 +1,101 @@ +""" +Host display geometry, in the units Firefox lays its windows out in. + +Firefox sizes windows in **CSS pixels**, but `screeninfo` marks the process +per-monitor DPI aware and therefore reports **physical** pixels. Where Windows +display scaling is enabled the two differ by the scale factor: a 1920x1080 panel +at 150% is only 1280x720 CSS px. Deriving a window size from the physical +numbers overshoots the screen by that factor, so the window opens partly +off-screen (daijro/camoufox#425). + +macOS (`NSScreen.frame`) and X11 (xrandr) already report CSS pixels, so scaling +only ever applies on Windows. +""" + +from typing import Any, NamedTuple, Optional + +from screeninfo import get_monitors + +from .pkgman import OS_NAME + +# Windows expresses DPI relative to this baseline: 144 DPI == 150% scaling. +_WINDOWS_BASE_DPI = 96 + +# shcore.h / winuser.h constants +_MDT_EFFECTIVE_DPI = 0 +_MONITOR_DEFAULTTONEAREST = 2 + + +class DisplaySize(NamedTuple): + """Size of a monitor in CSS pixels.""" + + width: int + height: int + + +def largest_display() -> Optional[DisplaySize]: + """ + Size of the roomiest attached monitor in CSS pixels, or None when the + display cannot be probed (no monitors, or enumeration failed). + """ + try: + monitors = get_monitors() + except Exception: + return None + if not monitors: + return None + + monitor = max(monitors, key=lambda m: m.width * m.height) + scale = _scale_factor(monitor) + return DisplaySize( + width=max(1, int(monitor.width / scale)), + height=max(1, int(monitor.height / scale)), + ) + + +def _scale_factor(monitor: Any) -> float: + """ + Physical pixels per CSS pixel on `monitor`. Always 1.0 outside Windows. + """ + if OS_NAME != 'win': + return 1.0 + try: + dpi = _windows_monitor_dpi(monitor) + except Exception: + return 1.0 # Pre-Windows 8.1, or the shcore call is unavailable + return dpi / _WINDOWS_BASE_DPI if dpi > 0 else 1.0 + + +def _windows_monitor_dpi(monitor: Any) -> int: + """ + Effective DPI of `monitor`, via shcore!GetDpiForMonitor (Windows 8.1+). + + Private WinDLL handles are used rather than the process-wide `ctypes.windll` + cache so that annotating the prototypes cannot affect other libraries. + """ + import ctypes + from ctypes import wintypes + + user32 = ctypes.WinDLL('user32') # type: ignore[attr-defined] + user32.MonitorFromPoint.argtypes = (wintypes.POINT, wintypes.DWORD) + user32.MonitorFromPoint.restype = wintypes.HANDLE + handle = user32.MonitorFromPoint( + wintypes.POINT(int(monitor.x), int(monitor.y)), _MONITOR_DEFAULTTONEAREST + ) + + shcore = ctypes.WinDLL('shcore') # type: ignore[attr-defined] + shcore.GetDpiForMonitor.argtypes = ( + wintypes.HANDLE, + ctypes.c_int, + ctypes.POINTER(wintypes.UINT), + ctypes.POINTER(wintypes.UINT), + ) + shcore.GetDpiForMonitor.restype = ctypes.c_long # HRESULT + dpi_x, dpi_y = wintypes.UINT(), wintypes.UINT() + + hresult = shcore.GetDpiForMonitor( + handle, _MDT_EFFECTIVE_DPI, ctypes.byref(dpi_x), ctypes.byref(dpi_y) + ) + if hresult != 0: + raise OSError(f'GetDpiForMonitor failed (0x{hresult & 0xFFFFFFFF:08X})') + return dpi_x.value diff --git a/pythonlib/camoufox/utils.py b/pythonlib/camoufox/utils.py index 88137bf..2f5f9ea 100644 --- a/pythonlib/camoufox/utils.py +++ b/pythonlib/camoufox/utils.py @@ -11,11 +11,11 @@ from typing import Any, Dict, List, Literal, Optional, Tuple, Union import numpy as np import orjson from browserforge.fingerprints import Fingerprint, Screen -from screeninfo import get_monitors from typing_extensions import TypeAlias from ua_parser import user_agent_parser from .addons import DefaultAddons, add_default_addons, confirm_paths +from .display import largest_display from .exceptions import ( InvalidOS, InvalidPropertyType, @@ -221,19 +221,16 @@ def determine_ua_os(user_agent: str) -> Literal['mac', 'win', 'lin']: def get_screen_cons(headless: Optional[bool] = None) -> Optional[Screen]: """ Determines a sane viewport size for Camoufox if being ran in headful mode. + + Bounds are CSS pixels, the unit Firefox lays its windows out in -- see + camoufox.display for why that differs from the monitor's physical size. """ if headless is False: return None # Skip if headless - try: - monitors = get_monitors() - except Exception: - return None # Skip if there's an error getting the monitors - if not monitors: - return None # Skip if there are no monitors - - # Use the dimensions from the monitor with greatest screen real estate - monitor = max(monitors, key=lambda m: m.width * m.height) - return Screen(max_width=monitor.width, max_height=monitor.height) + display = largest_display() + if display is None: + return None # Skip if the display can't be probed + return Screen(max_width=display.width, max_height=display.height) def update_fonts(config: Dict[str, Any], target_os: str) -> None: diff --git a/pythonlib/tests/test_display.py b/pythonlib/tests/test_display.py new file mode 100644 index 0000000..0729b87 --- /dev/null +++ b/pythonlib/tests/test_display.py @@ -0,0 +1,92 @@ +""" +Tests for camoufox.display -- probing the host monitor in CSS pixels. + +Guards daijro/camoufox#425: with Windows display scaling enabled, screeninfo +reports physical pixels while Firefox lays windows out in CSS pixels, so the +browser window opened larger than the screen. + +Run with: + cd pythonlib && python -m pytest tests/test_display.py -v +""" + +import os +import sys +from types import SimpleNamespace + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import pytest # noqa: E402 + +from camoufox import display # noqa: E402 + + +def monitor(width, height, x=0, y=0): + return SimpleNamespace(width=width, height=height, x=x, y=y) + + +@pytest.fixture +def monitors(monkeypatch): + """Stub out screeninfo with an explicit monitor list.""" + + def _set(*found): + monkeypatch.setattr(display, "get_monitors", lambda: list(found)) + + return _set + + +class TestLargestDisplay: + def test_reports_unscaled_display_verbatim(self, monkeypatch, monitors): + monkeypatch.setattr(display, "OS_NAME", "lin") + monitors(monitor(1920, 1080)) + assert display.largest_display() == (1920, 1080) + + @pytest.mark.parametrize( + ("dpi", "expected"), + [(96, (1920, 1080)), (120, (1536, 864)), (144, (1280, 720)), (192, (960, 540))], + ) + def test_windows_scaling_converts_to_css_pixels( + self, monkeypatch, monitors, dpi, expected + ): + monkeypatch.setattr(display, "OS_NAME", "win") + monkeypatch.setattr(display, "_windows_monitor_dpi", lambda m: dpi) + monitors(monitor(1920, 1080)) + assert display.largest_display() == expected + + def test_scaling_is_windows_only(self, monkeypatch, monitors): + """macOS and X11 already report CSS pixels; never rescale them.""" + monkeypatch.setattr(display, "_windows_monitor_dpi", lambda m: 192) + monitors(monitor(1920, 1080)) + for os_name in ("mac", "lin"): + monkeypatch.setattr(display, "OS_NAME", os_name) + assert display.largest_display() == (1920, 1080) + + def test_falls_back_to_1x_when_dpi_lookup_fails(self, monkeypatch, monitors): + def unavailable(_): + raise OSError("GetDpiForMonitor failed") + + monkeypatch.setattr(display, "OS_NAME", "win") + monkeypatch.setattr(display, "_windows_monitor_dpi", unavailable) + monitors(monitor(1920, 1080)) + assert display.largest_display() == (1920, 1080) + + def test_falls_back_to_1x_on_nonsense_dpi(self, monkeypatch, monitors): + monkeypatch.setattr(display, "OS_NAME", "win") + monkeypatch.setattr(display, "_windows_monitor_dpi", lambda m: 0) + monitors(monitor(1920, 1080)) + assert display.largest_display() == (1920, 1080) + + def test_picks_the_roomiest_monitor(self, monkeypatch, monitors): + monkeypatch.setattr(display, "OS_NAME", "lin") + monitors(monitor(1280, 720), monitor(2560, 1440), monitor(1920, 1080)) + assert display.largest_display() == (2560, 1440) + + def test_none_when_no_monitors(self, monitors): + monitors() + assert display.largest_display() is None + + def test_none_when_enumeration_raises(self, monkeypatch): + def boom(): + raise RuntimeError("no display") + + monkeypatch.setattr(display, "get_monitors", boom) + assert display.largest_display() is None