headless: cloak on Windows/macOS, Xvfb on Linux; CI cloak + webgl-masking guards

headless=True now hides the window via the binary's own cloak pref
(zoom.stealth.cloak_windows) on Windows and macOS instead of the broken
thread-level SetThreadDesktop; macOS is now supported. Linux keeps Xvfb.

Adds e2e guards that also run per-platform in the release drive-gate:
- test_cloak: the window is hidden (Windows DWMWA_CLOAKED / macOS CGWindowAlpha)
  yet still renders + drives; the macOS leg is where the cocoa cloak patch runs.
- a WebGL readPixels masking guard: the gamma noise must stay a smooth gamma
  remap, not the pixelscan-maskable +-1 spikes.
This commit is contained in:
feder-cr 2026-06-11 11:58:14 +02:00
parent e524695088
commit c2103ed0db
6 changed files with 271 additions and 147 deletions

View file

@ -341,6 +341,26 @@ jobs:
shell: bash
run: python scripts/ci_drive_gate.py "$FF_EXE" ${{ matrix.extra }}
# CLOAK + WEBGL-MASKING GUARDS — run the wrapper's e2e cloak/gamma checks
# against THIS leg's freshly-built artifact, on its native runner. The
# wrapper's headless=True is headed+hidden (cloak on Win/macOS, its own
# Xvfb on Linux), so software-GL rendering works on the GPU-less hosts.
# test_cloak asserts the window is hidden (Windows DWMWA_CLOAKED / macOS
# CGWindowAlpha) AND still renders — the macOS leg is the only place the
# cocoa cloak patch gets RUN. The webgl guard catches a regression of the
# gamma readPixels noise back to the pixelscan-maskable ±1 spike form.
- name: Install pyobjc Quartz (macOS — to read the cloak window alpha)
if: matrix.kind == 'mac'
run: python -m pip install --quiet pyobjc-framework-Quartz
- name: Cloak + WebGL-masking guards (headed)
shell: bash
run: |
python -m pip install --quiet -e .
INVPW_BINARY_PATH="$FF_EXE" python -m pytest \
tests/test_cloak.py \
"tests/test_fingerprint_surface.py::test_webgl_readpixels_no_masking_signature" \
-m e2e -o addopts='' -q
publish:
name: publish-draft-release
needs: [build, gate]

View file

@ -2,18 +2,23 @@
Playwright's ``headless=True`` flips Firefox onto a different code path —
no widget tree, software-only rendering, distinct timing and anti-bot
systems can spot the divergence. Running the browser *headed* on a
virtual display gives us the real rendering pipeline while keeping the
windows off the user's screen.
systems can spot the divergence. Running the browser *headed* but hidden
gives us the real rendering pipeline while keeping the windows off screen.
Linux: spawns its own ``Xvfb`` instance, points ``DISPLAY`` at it.
Windows: creates a hidden desktop via ``CreateDesktop`` and binds the
calling thread to it, so Playwright's child processes inherit it.
Two mechanisms, by platform:
- **Windows & macOS**: the patched binary cloaks its OWN chrome windows
when ``zoom.stealth.cloak_windows`` is set ``DWMWA_CLOAK`` (Windows)
/ ``NSWindow`` alpha-0 + pinned occlusion-ignore (macOS). The window
renders on the real GPU but never appears on screen, in the taskbar or
the Dock. The launcher injects the pref; nothing host-side is spawned.
- **Linux**: spawns its own ``Xvfb`` instance and points ``DISPLAY`` at
it (X11/Wayland have no per-window cloak that keeps the GPU rendering).
"""
from __future__ import annotations
import os
import secrets
import subprocess
import sys
import time
@ -131,95 +136,40 @@ class _LinuxVirtualDisplay:
self._display = None
class _WindowsVirtualDesktop:
"""A hidden Windows desktop the calling thread is bound to.
# Windows & macOS: the patched Firefox cloaks its own chrome windows when this
# pref is set (DWMWA_CLOAK / NSWindow alpha-0 + pinned occlusion-ignore), so the
# window renders on the real GPU but never shows on screen / in the taskbar or
# Dock. window_occlusion_tracking is disabled so a hidden window keeps painting.
CLOAK_PREFS = {
"zoom.stealth.cloak_windows": True,
"widget.windows.window_occlusion_tracking.enabled": False,
}
Playwright's child processes (node driver → firefox.exe) inherit the
desktop because their ``STARTUPINFO.lpDesktop`` is NULL Windows uses
the calling thread's desktop in that case.
pywin32 ships ``CreateDesktop`` in ``win32service`` but doesn't expose
``SetThreadDesktop`` / ``GetThreadDesktop`` as module functions. We
call them directly via ctypes against ``user32.dll``.
def cloak_prefs() -> dict:
"""Prefs that make the patched binary self-cloak its chrome windows.
Used on Windows & macOS, where hiding is done inside the binary rather than
with a host-side virtual display.
"""
def __init__(self) -> None:
self._desktop = None # PyHDESK from win32service.CreateDesktop
self._original_handle = 0 # raw HDESK int of the previous desktop
def start(self) -> None:
try:
import win32con # type: ignore
import win32service # type: ignore
except ImportError as e:
raise RuntimeError(
"invisible_playwright headless=True on Windows requires pywin32. "
"Install it: pip install pywin32"
) from e
import ctypes
from ctypes import wintypes
user32 = ctypes.windll.user32
kernel32 = ctypes.windll.kernel32
# Save the current desktop handle so we can restore it on stop().
get_thread_desktop = user32.GetThreadDesktop
get_thread_desktop.argtypes = [wintypes.DWORD]
get_thread_desktop.restype = wintypes.HANDLE
self._original_handle = get_thread_desktop(kernel32.GetCurrentThreadId())
name = f"sf_{secrets.token_hex(4)}"
self._desktop = win32service.CreateDesktop(
name, 0, win32con.GENERIC_ALL, None
)
# Bind the calling thread to the new desktop. Children spawned
# afterwards (Playwright driver → firefox.exe) inherit it because
# their STARTUPINFO.lpDesktop is NULL.
set_thread_desktop = user32.SetThreadDesktop
set_thread_desktop.argtypes = [wintypes.HANDLE]
set_thread_desktop.restype = wintypes.BOOL
if not set_thread_desktop(int(self._desktop)):
err = ctypes.get_last_error()
raise RuntimeError(
f"SetThreadDesktop failed (GetLastError={err}). "
"The thread cannot have any windows or hooks; close them first."
)
def stop(self) -> None:
import ctypes
from ctypes import wintypes
user32 = ctypes.windll.user32
if self._original_handle:
try:
set_thread_desktop = user32.SetThreadDesktop
set_thread_desktop.argtypes = [wintypes.HANDLE]
set_thread_desktop.restype = wintypes.BOOL
set_thread_desktop(self._original_handle)
except Exception:
pass
self._original_handle = 0
if self._desktop is not None:
try:
self._desktop.CloseDesktop()
except Exception:
pass
self._desktop = None
return dict(CLOAK_PREFS)
def make_virtual_display():
"""Return a started/stoppable virtual-display object for this platform.
"""Return a start()/stop()-able virtual display, or ``None`` when the
platform hides windows via the in-binary cloak pref instead.
InvisiblePlaywright supports Windows x86_64 and Linux x86_64 only.
- Linux: a fresh ``Xvfb`` (the launcher start()s/stop()s it).
- Windows / macOS: ``None`` the binary self-cloaks via ``cloak_prefs()``,
injected by the launcher; nothing host-side needs spawning.
"""
if sys.platform == "win32":
return _WindowsVirtualDesktop()
if sys.platform.startswith("linux"):
return _LinuxVirtualDisplay()
if sys.platform in ("win32", "darwin"):
return None
raise RuntimeError(
f"invisible_playwright supports Windows and Linux only (got {sys.platform!r})"
f"invisible_playwright supports Windows, macOS and Linux "
f"(got {sys.platform!r})"
)

View file

@ -9,7 +9,7 @@ from playwright.sync_api import Browser, BrowserContext, Playwright, sync_playwr
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 .prefs import translate_profile_to_prefs
@ -340,6 +340,12 @@ class InvisiblePlaywright:
extra_prefs=self._extra_prefs,
virtual_display=bool(self._headless and _sys.platform == "win32"),
)
# 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.
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)
if self._humanize:
prefs["invisible_playwright.humanize.maxTime"] = str(self._humanize_max_seconds())
@ -379,15 +385,18 @@ class InvisiblePlaywright:
def _resolve_headless(self) -> bool:
"""Translate the user's ``headless`` flag.
When ``True``, we keep Firefox in headed mode (real rendering
pipeline coherent fingerprint) and hide the windows on a fresh
Xvfb (Linux) or hidden Windows desktop.
When ``True``, Firefox stays in headed mode (real rendering pipeline
coherent fingerprint) and the window is hidden: on Linux via a fresh
Xvfb spawned here; on Windows/macOS via the binary's own window cloak
(the ``zoom.stealth.cloak_windows`` pref added in ``_build_prefs``), so
``make_virtual_display()`` returns ``None`` and nothing is spawned.
"""
if not self._headless:
return False
vd = make_virtual_display()
vd.start()
self._virtual_display = vd
if vd is not None:
vd.start()
self._virtual_display = vd
return False
def _humanize_max_seconds(self) -> float:

106
tests/test_cloak.py Normal file
View file

@ -0,0 +1,106 @@
"""Cloak guard (e2e) — verifies the source-level "invisible headless" cloak:
the chrome window is hidden from the screen YET keeps rendering on the real GPU
(not Playwright's native headless, which has no WebGL). Runs per-platform in CI:
- Windows: the DWMWA_CLOAK attribute (queried via DWMWA_CLOAKED).
- macOS: the NSWindow alpha (queried via Quartz CGWindowListCopyWindowInfo).
- Linux: skipped there the wrapper hides via Xvfb, not a source-level cloak.
This is the CI validation for the macOS cocoa cloak patch, which can't be built
or run on the Windows/Linux dev boxes.
"""
from __future__ import annotations
import sys
import time
import pytest
from invisible_playwright import InvisiblePlaywright
CLOAK_PREFS = {
"zoom.stealth.cloak_windows": True,
"widget.windows.window_occlusion_tracking.enabled": False,
}
_WEBGL_RENDERER = """() => {
const g = document.createElement('canvas').getContext('webgl');
if (!g) return 'NO-WEBGL';
const d = g.getExtension('WEBGL_debug_renderer_info');
return d ? g.getParameter(d.UNMASKED_RENDERER_WEBGL) : (g.getParameter(g.RENDERER) || '');
}"""
def _windows_moz_window_cloaked() -> bool:
"""True if at least one MozillaWindowClass top-level window is DWMWA_CLOAKED."""
import ctypes
from ctypes import wintypes
user32 = ctypes.windll.user32
dwm = ctypes.windll.dwmapi
DWMWA_CLOAKED = 14
ENUM = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
found = []
def cb(hwnd, _):
c = ctypes.create_unicode_buffer(256)
user32.GetClassNameW(hwnd, c, 256)
if c.value == "MozillaWindowClass":
v = wintypes.DWORD(0)
dwm.DwmGetWindowAttribute(wintypes.HWND(hwnd), DWMWA_CLOAKED,
ctypes.byref(v), 4)
found.append(v.value)
return True
user32.EnumWindows(ENUM(cb), 0)
return any(state != 0 for state in found)
def _macos_firefox_window_alpha_zero() -> bool:
"""True if a Firefox on-screen window reports ~0 alpha (cloaked)."""
from Quartz import ( # type: ignore
CGWindowListCopyWindowInfo,
kCGWindowListOptionOnScreenOnly,
kCGNullWindowID,
)
infos = CGWindowListCopyWindowInfo(kCGWindowListOptionOnScreenOnly, kCGNullWindowID)
alphas = []
for w in infos or []:
owner = (w.get("kCGWindowOwnerName") or "")
if "firefox" in owner.lower() or "nightly" in owner.lower():
alphas.append(float(w.get("kCGWindowAlpha", 1.0)))
# cloaked windows are alpha 0; if Firefox has any window it must be ~0.
return bool(alphas) and all(a < 0.05 for a in alphas)
@pytest.mark.e2e
@pytest.mark.skipif(
sys.platform.startswith("linux"),
reason="source-level cloak is Windows/macOS only; Linux hides via Xvfb",
)
def test_cloak_hides_window_but_keeps_rendering(firefox_binary):
with InvisiblePlaywright(
seed=42, binary_path=firefox_binary, headless=False, extra_prefs=CLOAK_PREFS
) as browser:
page = browser.new_context().new_page()
page.goto("https://example.com", timeout=30_000)
time.sleep(2)
# 1) still renders on the real GPU pipeline (a non-blank screenshot proves
# the compositor is alive despite the window being hidden).
shot = page.screenshot()
assert len(shot) > 3000, "cloaked window produced a blank screenshot (rendering paused)"
# 2) real WebGL present (native headless has none) -> headed pipeline intact.
renderer = page.evaluate(_WEBGL_RENDERER)
assert renderer and renderer != "NO-WEBGL", f"no real WebGL under cloak: {renderer!r}"
# 3) the window is actually hidden (per-platform).
if sys.platform == "win32":
assert _windows_moz_window_cloaked(), "Firefox window is not DWMWA_CLOAKED"
elif sys.platform == "darwin":
try:
hidden = _macos_firefox_window_alpha_zero()
except ImportError:
pytest.skip("pyobjc Quartz not available to verify macOS cloak alpha")
assert hidden, "Firefox macOS window is not alpha-cloaked"

View file

@ -236,3 +236,69 @@ def test_fpscanner_no_userAgentData_on_firefox(page):
"""navigator.userAgentData is Chromium-only. Presence on Firefox UA = bot."""
if "Firefox" in _ev(page, "navigator.userAgent"):
assert not _ev(page, "'userAgentData' in navigator")
# ===========================================================================
# WebGL masking-detector guard (pixelscan getFixedRedBox / webglHash)
#
# pixelscan flags "fingerprint masking" on the WebGL readPixels output. We
# reproduce ITS probe locally (the fingerprintjs gradient triangle) and check
# the structural signature it keys on: our stealth readPixels noise MUST be a
# coherent, monotonic gamma remap (smooth, ~0 spikes), NOT isolated +-1 flips
# (which read as unnatural high-frequency noise and were flagged as masking).
# This is the CI-safe local stand-in for pixelscan's server-side check; it
# guards the gamma fix from ever silently regressing to the +-1 algorithm.
# ===========================================================================
_WEBGL_MASKING_PROBE = """() => {
const c = document.createElement('canvas');
const gl = c.getContext('webgl') || c.getContext('experimental-webgl');
if (!gl) return { error: 'no-webgl' };
const vs = 'attribute vec2 a;uniform vec2 o;varying vec2 v;' +
'void main(){v=a+o;gl_Position=vec4(a,0,1);}';
const fs = 'precision mediump float;varying vec2 v;' +
'void main(){gl_FragColor=vec4(v,0,1);}';
const buf = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER,
new Float32Array([-0.2,-0.9,0, 0.4,-0.26,0, 0,0.732134444,0]), gl.STATIC_DRAW);
const p = gl.createProgram();
const s1 = gl.createShader(gl.VERTEX_SHADER); gl.shaderSource(s1, vs); gl.compileShader(s1);
const s2 = gl.createShader(gl.FRAGMENT_SHADER); gl.shaderSource(s2, fs); gl.compileShader(s2);
gl.attachShader(p, s1); gl.attachShader(p, s2); gl.linkProgram(p); gl.useProgram(p);
const loc = gl.getAttribLocation(p, 'a'); gl.enableVertexAttribArray(loc);
gl.vertexAttribPointer(loc, 3, gl.FLOAT, false, 0, 0);
const off = gl.getUniformLocation(p, 'o'); gl.uniform2f(off, 1, 1);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 3);
const w = gl.drawingBufferWidth, h = gl.drawingBufferHeight;
const px = new Uint8Array(w * h * 4);
gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, px);
// count small local extrema (|delta|<=3 to both horizontal neighbours, same
// sign) the +-1-noise signature; a smooth/monotonic render has ~none.
let spikes = 0;
for (let y = 0; y < h; y++) {
for (let x = 1; x < w - 1; x++) {
for (let ch = 0; ch < 3; ch++) {
const i = (y * w + x) * 4 + ch; const val = px[i];
if (val === 0) continue;
const dl = val - px[i - 4], dr = val - px[i + 4];
if (dl * dr > 0 && Math.abs(dl) <= 3 && Math.abs(dr) <= 3) spikes++;
}
}
}
return { spikes: spikes, dims: w + 'x' + h };
}"""
@pytest.mark.e2e
def test_webgl_readpixels_no_masking_signature(page):
"""Stealth WebGL readPixels noise must be a coherent gamma remap (smooth),
not isolated +-1 flips. +-1 noise on the smooth gradient triangle produced
~300+ 'spikes' and pixelscan flagged it as masking; the gamma remap leaves
the gradient smooth (~0 spikes). Regression guard for the gamma fix."""
res = _ev(page, _WEBGL_MASKING_PROBE)
assert "error" not in res, f"WebGL probe failed: {res}"
# genuine / gamma -> ~0; the rejected +-1 algorithm produced ~320.
assert res["spikes"] < 30, (
f"WebGL readPixels shows {res['spikes']} high-frequency noise spikes "
f"(pixelscan-maskable); the stealth noise must be a smooth gamma remap."
)

View file

@ -1,35 +1,39 @@
"""Unit tests for the ``_headless`` virtual-display dispatcher.
"""Unit tests for the ``_headless`` window-hider dispatcher.
The dispatcher (``make_virtual_display``) is the only piece of
``_headless`` we can exercise as a unit test on a single platform:
``_WindowsVirtualDesktop`` actually creates a Win32 desktop on
construction's later ``start()`` call, and ``_LinuxVirtualDisplay`` calls
``Xvfb`` both belong in integration/E2E coverage. The dispatcher's
job is pure platform routing, which we patch via ``monkeypatch``.
``make_virtual_display`` is pure platform routing:
- Linux: a ``_LinuxVirtualDisplay`` (Xvfb) object the launcher start()s/stop()s.
- Windows / macOS: ``None`` the patched binary self-cloaks its chrome windows
via ``cloak_prefs()`` (injected by the launcher), so nothing host-side spawns.
- Anything else: a clear ``RuntimeError`` naming the platform.
Per scope: Windows-specific + platform-agnostic only. We still cover
the Linux dispatch branch because instantiating ``_LinuxVirtualDisplay``
does no I/O Xvfb is only spawned in ``start()``, which we never call.
``_LinuxVirtualDisplay`` construction does no I/O (Xvfb is only spawned in
``start()``), so it's safe to exercise on any host.
"""
from __future__ import annotations
import sys
import pytest
import invisible_playwright._headless as headless
from invisible_playwright._headless import (
CLOAK_PREFS,
_LinuxVirtualDisplay,
_WindowsVirtualDesktop,
cloak_prefs,
make_virtual_display,
)
@pytest.mark.unit
def test_make_virtual_display_returns_windows_desktop_on_win32(monkeypatch):
def test_make_virtual_display_returns_none_on_win32(monkeypatch):
"""Windows hides via the in-binary cloak pref, not a host-side display."""
monkeypatch.setattr(headless.sys, "platform", "win32")
vd = make_virtual_display()
assert isinstance(vd, _WindowsVirtualDesktop)
assert make_virtual_display() is None
@pytest.mark.unit
def test_make_virtual_display_returns_none_on_darwin(monkeypatch):
"""macOS is now supported — it hides via the same in-binary cloak pref."""
monkeypatch.setattr(headless.sys, "platform", "darwin")
assert make_virtual_display() is None
@pytest.mark.unit
@ -37,8 +41,7 @@ def test_make_virtual_display_returns_linux_xvfb_on_linux(monkeypatch):
"""``__init__`` of ``_LinuxVirtualDisplay`` does no I/O — only ``start()``
spawns Xvfb. Exercising the dispatcher here is safe on any host."""
monkeypatch.setattr(headless.sys, "platform", "linux")
vd = make_virtual_display()
assert isinstance(vd, _LinuxVirtualDisplay)
assert isinstance(make_virtual_display(), _LinuxVirtualDisplay)
@pytest.mark.unit
@ -49,21 +52,10 @@ def test_make_virtual_display_accepts_linux_variants(monkeypatch):
assert isinstance(make_virtual_display(), _LinuxVirtualDisplay)
@pytest.mark.unit
def test_make_virtual_display_raises_on_darwin(monkeypatch):
"""macOS is unsupported — the dispatcher must raise with a clear
message rather than returning a no-op shim. ``InvisiblePlaywright``
relies on this to bail before launching Firefox on a system where
the patched binary doesn't exist."""
monkeypatch.setattr(headless.sys, "platform", "darwin")
with pytest.raises(RuntimeError, match="Windows and Linux only"):
make_virtual_display()
@pytest.mark.unit
def test_make_virtual_display_raises_on_unsupported_platform(monkeypatch):
monkeypatch.setattr(headless.sys, "platform", "freebsd14")
with pytest.raises(RuntimeError, match="Windows and Linux only"):
with pytest.raises(RuntimeError, match="Windows, macOS and Linux"):
make_virtual_display()
@ -77,32 +69,13 @@ def test_make_virtual_display_error_mentions_offending_platform(monkeypatch):
@pytest.mark.unit
def test_windows_desktop_initial_state_is_clean():
"""Construction must not allocate Win32 resources — only ``start()``
does. Allows users to instantiate ``InvisiblePlaywright`` without
pywin32 installed; the import error fires lazily when ``start()`` runs."""
vd = _WindowsVirtualDesktop()
assert vd._desktop is None
assert vd._original_handle == 0
@pytest.mark.unit
@pytest.mark.skipif(sys.platform != "win32", reason="exercises Win32 ctypes")
def test_windows_desktop_stop_is_idempotent_without_start():
"""``stop()`` after never calling ``start()`` must be a no-op, so
``__exit__`` from a failed launch can call it unconditionally.
Skipped off Windows because ``stop()`` unconditionally resolves
``ctypes.windll.user32`` at the top of the function that symbol
only exists on Windows. The early-return logic is safe because
callers only instantiate this class via ``make_virtual_display()``
which already routes on ``sys.platform == 'win32'``.
"""
vd = _WindowsVirtualDesktop()
vd.stop()
vd.stop()
assert vd._desktop is None
assert vd._original_handle == 0
def test_cloak_prefs_enables_cloak_and_disables_occlusion():
"""The cloak prefs must turn on the in-binary cloak and turn OFF Windows
occlusion tracking (so a hidden window keeps painting). Returns a copy."""
p = cloak_prefs()
assert p["zoom.stealth.cloak_windows"] is True
assert p["widget.windows.window_occlusion_tracking.enabled"] is False
assert p == CLOAK_PREFS and p is not CLOAK_PREFS
# ──────────────────────────────────────────────────────────────────────