diff --git a/src/invisible_playwright/async_api.py b/src/invisible_playwright/async_api.py index ce08608..e8e1f2b 100644 --- a/src/invisible_playwright/async_api.py +++ b/src/invisible_playwright/async_api.py @@ -10,7 +10,7 @@ from playwright.async_api import Browser, BrowserContext, Playwright, async_play from ._fpforge import Profile, generate_profile from ._geo import prepare_session_geo -from ._headless import make_virtual_display +from ._headless import cloak_prefs, make_virtual_display from ._proxy import configure_proxy as _configure_proxy_shared from .download import ensure_binary from .launcher import _CHROME_H, _CHROME_W, _TASKBAR_H, _tz_env @@ -95,10 +95,19 @@ class InvisiblePlaywright: extra_prefs=self._extra_prefs, virtual_display=bool(self._headless and _sys.platform == "win32"), ) - prefs["invisible_playwright.humanize"] = bool(self._humanize) + # Windows & macOS hide the headless window via the binary's own cloak + # (DWMWA_CLOAK / NSWindow alpha) — inject the pref so the patched build + # cloaks its chrome windows. setdefault: an explicit user override wins. + # (Mirrors launcher._build_prefs; the sync path always did this, async + # didn't — so async headless=True never cloaked AND crashed below.) + if self._headless and _sys.platform in ("win32", "darwin"): + for _k, _v in cloak_prefs().items(): + prefs.setdefault(_k, _v) + # stealthfox.* is the namespace the binary's Juggler reads (see launcher.py note). + prefs["stealthfox.humanize"] = bool(self._humanize) if self._humanize: cap = 1.5 if self._humanize is True else float(self._humanize) - prefs["invisible_playwright.humanize.maxTime"] = str(cap) + prefs["stealthfox.humanize.maxTime"] = str(cap) playwright_proxy = _configure_proxy_shared(self._proxy, prefs) pw_headless = self._resolve_headless() env = self._build_env() @@ -223,8 +232,13 @@ class InvisiblePlaywright: if not self._headless: return False vd = make_virtual_display() - vd.start() - self._virtual_display = vd + # Linux: Xvfb to start. Windows/macOS: make_virtual_display() returns + # None (the binary self-cloaks via cloak_prefs injected in __aenter__), + # so there is nothing to start — guarding the None was the missing piece + # that made async headless=True crash with AttributeError on Windows. + if vd is not None: + vd.start() + self._virtual_display = vd return False diff --git a/src/invisible_playwright/config.py b/src/invisible_playwright/config.py index c411512..2a7300e 100644 --- a/src/invisible_playwright/config.py +++ b/src/invisible_playwright/config.py @@ -91,10 +91,11 @@ def get_default_stealth_prefs( extra_prefs=extra_prefs, virtual_display=virtual_display, ) - prefs["invisible_playwright.humanize"] = bool(humanize) + # stealthfox.* is the namespace the binary's Juggler reads (see launcher.py note). + prefs["stealthfox.humanize"] = bool(humanize) if humanize: max_seconds = float(humanize) if not isinstance(humanize, bool) else 1.5 - prefs["invisible_playwright.humanize.maxTime"] = str(max_seconds) + prefs["stealthfox.humanize.maxTime"] = str(max_seconds) return prefs diff --git a/src/invisible_playwright/launcher.py b/src/invisible_playwright/launcher.py index c07238b..bcee7ae 100644 --- a/src/invisible_playwright/launcher.py +++ b/src/invisible_playwright/launcher.py @@ -346,9 +346,13 @@ class InvisiblePlaywright: if self._headless and _sys.platform in ("win32", "darwin"): for _k, _v in cloak_prefs().items(): prefs.setdefault(_k, _v) - prefs["invisible_playwright.humanize"] = bool(self._humanize) + # Pref namespace MUST be stealthfox.* — that's what the binary's Juggler + # reads (PageHandler.js gates the Bezier mouse path on `stealthfox.humanize`). + # The old `invisible_playwright.*` name was a dead no-op (nothing read it), so + # humanize silently never fired and every click teleported the cursor. + prefs["stealthfox.humanize"] = bool(self._humanize) if self._humanize: - prefs["invisible_playwright.humanize.maxTime"] = str(self._humanize_max_seconds()) + prefs["stealthfox.humanize.maxTime"] = str(self._humanize_max_seconds()) return prefs def _build_env(self) -> Dict[str, str]: diff --git a/tests/test_mouse.py b/tests/test_mouse.py index d915b9e..03d3206 100644 --- a/tests/test_mouse.py +++ b/tests/test_mouse.py @@ -132,12 +132,9 @@ def test_mouse_move_outside_viewport_does_not_raise(firefox_binary): # ──────────────────────────────────────────────────────────────────── -@pytest.mark.e2e -def test_humanize_emits_intermediate_moves(firefox_binary): - """A long mouse.move from one corner to another should fire several - mousemove events on the page when the humanize hook is enabled (which - is the StealthFox default).""" - with InvisiblePlaywright(seed=42, binary_path=firefox_binary) as browser: +def _humanize_move_count(firefox_binary, humanize): + """Count page mousemove events fired by ONE long mouse.move.""" + with InvisiblePlaywright(seed=42, binary_path=firefox_binary, humanize=humanize) as browser: page = browser.new_page() page.goto(_data_url( "