mirror of
https://github.com/daijro/camoufox.git
synced 2026-08-23 16:00:07 +00:00
Probe the host monitor in CSS pixels
screeninfo makes the process per-monitor DPI aware, so it reports physical pixels, while Firefox lays windows out in CSS pixels. At 150% Windows scaling a 1920x1080 panel is 1280x720 CSS px, so bounding the fingerprint by the physical size lets the window open 1.5x larger than the screen. Refs #425
This commit is contained in:
committed by
Jake Writer
parent
fbafbcf9f0
commit
22c6ffbdda
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user