Add macOS support

This commit is contained in:
dannyward630 2026-05-17 21:05:20 -04:00
parent 143aff4bd2
commit d09200f01d
10 changed files with 240 additions and 50 deletions

View file

@ -59,7 +59,9 @@ pip install git+https://github.com/feder-cr/invisible_playwright.git
python -m invisible_playwright fetch # one-time ~100 MB download, SHA256-verified
```
Supported platforms: **Windows x86_64**, **Linux x86_64**.
Supported platforms: **Windows x86_64**, **Linux x86_64**, **macOS x86_64**, **macOS arm64**.
`headless=True` keeps using the hidden headed-mode backends on Windows/Linux. On macOS, launch support is available but hidden headed-mode is not yet implemented, so use `headless=False`.
---

View file

@ -9,6 +9,8 @@ windows off the user's 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.
macOS: launch support exists, but there is currently no hidden-display
backend for ``headless=True``.
"""
from __future__ import annotations
@ -212,14 +214,22 @@ class _WindowsVirtualDesktop:
def make_virtual_display():
"""Return a started/stoppable virtual-display object for this platform.
InvisiblePlaywright supports Windows x86_64 and Linux x86_64 only.
Hidden-display backends currently exist for Windows and Linux only.
On macOS the wrapper can launch normally, but ``headless=True`` is
not yet supported because there is no invisible headed-mode backend.
"""
if sys.platform == "win32":
return _WindowsVirtualDesktop()
if sys.platform.startswith("linux"):
return _LinuxVirtualDisplay()
if sys.platform == "darwin":
raise RuntimeError(
"invisible_playwright headless=True is not yet supported on macOS. "
"Use headless=False."
)
raise RuntimeError(
f"invisible_playwright supports Windows and Linux only (got {sys.platform!r})"
f"invisible_playwright headless=True supports Windows and Linux only "
f"(got {sys.platform!r})"
)

View file

@ -19,11 +19,20 @@ BINARY_BASENAME: str = f"firefox-{FIREFOX_UPSTREAM_VERSION}-stealth"
def ARCHIVE_NAME(platform_key: str, machine: str) -> str:
"""Return the platform-specific archive filename.
platform_key: sys.platform ("win32", "linux")
machine: platform.machine() ("AMD64", "x86_64", ...)
platform_key: sys.platform ("win32", "linux", "darwin")
machine: platform.machine() ("AMD64", "x86_64", "arm64", ...)
"""
pk = platform_key.lower()
m = machine.lower()
if pk == "darwin":
if m in {"arm64", "aarch64"}:
arch = "arm64"
elif m in {"amd64", "x86_64"}:
arch = "x86_64"
else:
raise NotImplementedError(f"unsupported arch: {machine}")
return f"{BINARY_BASENAME}-macos-{arch}.tar.gz"
if m in {"amd64", "x86_64"}:
arch = "x86_64"
else:
@ -40,6 +49,7 @@ def ARCHIVE_NAME(platform_key: str, machine: str) -> str:
BINARY_ENTRY_REL = {
"win32": "firefox.exe",
"linux": "firefox",
"darwin": "Firefox.app/Contents/MacOS/firefox",
}
# GitHub release URL template. The "TODO" owner is resolved at publication time.

View file

@ -431,11 +431,11 @@ def _accept_language(locale: str) -> str:
def _font_metrics_for_platform(profile_metrics: str) -> str:
"""Return ``zoom.stealth.font.metrics`` value.
Windows: empty string. The C++ width-scale hook is a no-op and
Firefox renders Arial/Segoe/Calibri/etc. at their native canonical
widths. Applying the Bayesian-sampled per-font factors on a Windows
build would *distort* real metrics and surface as a font_preferences
width anomaly to FP Pro / reCAPTCHA.
Windows/macOS: empty string. The C++ width-scale hook is a no-op and
native desktop builds render Arial/Segoe/Calibri/etc. through their
host text stacks. Applying the Bayesian-sampled per-font factors on a
non-Linux build would *distort* real metrics and surface as a
font_preferences width anomaly to FP Pro / reCAPTCHA.
Linux: prepend generic-family compensation factors so DejaVu /
Liberation render at the widths Windows JS expects, then append the
@ -446,7 +446,7 @@ def _font_metrics_for_platform(profile_metrics: str) -> str:
return ""
if sys.platform.startswith("linux"):
return _LINUX_GENERIC_FONT_FACTORS + profile_metrics
return "" # Windows: NEVER apply width-scale factors.
return "" # Non-Linux builds: never apply width-scale factors.
def translate_profile_to_prefs(
@ -473,13 +473,13 @@ def translate_profile_to_prefs(
# GPU / WebGL renderer/vendor.
# On Linux we spoof to a Windows ANGLE renderer string (profile.gpu.renderer)
# so cross-platform sessions report a consistent Windows GPU identity.
# On Windows, spoofing a different GPU creates a renderer/parameters hash
# On non-Linux hosts, spoofing a different GPU creates a renderer/parameters hash
# mismatch: FP Pro hashes all 81 CN-set getParameter() values including
# enum 7937 (RENDERER). Setting GTX 980 while ANGLE returns Intel Arc A750
# parameters produces an OOD (hash 23d0a74b vs vanilla 66544db) that FP Pro
# ML scores at ~0.70 (confirmed: direct SF146 vs vanilla on same machine).
# Fix: leave renderer/vendor empty on Windows → ANGLE reports native hardware
# (SanitizeRenderer path at ClientWebGLContext.cpp:2592-2595) → consistent.
# Fix: leave renderer/vendor empty on non-Linux builds so the native stack
# reports its own hardware path and keeps the renderer/parameter hash coherent.
if sys.platform.startswith("linux"):
prefs["zoom.stealth.webgl.renderer"] = profile.gpu.renderer
prefs["zoom.stealth.webgl.vendor"] = profile.gpu.vendor
@ -489,10 +489,9 @@ def translate_profile_to_prefs(
prefs["zoom.stealth.webgl.vendor"] = ""
_renderer_lo = "intel" # test hardware is Intel Arc A750
# MSAA: on Windows, pin to 4 (Firefox default for ANGLE) so gl.SAMPLES is
# MSAA: on non-Linux hosts, pin to 4 so gl.SAMPLES is
# constant across all sessions. Different MSAA values cause different CN-set
# parameters hashes even with the same renderer → detectable variation.
# Vanilla Intel Arc A750 parameters hash (66544db8) verified at msaa=4.
_msaa = profile.webgl.msaa_samples if sys.platform.startswith("linux") else 4
prefs["zoom.stealth.webgl.msaa"] = _msaa
prefs["webgl.msaa-samples"] = _msaa

View file

@ -81,3 +81,15 @@ def test_async_default_context_kwargs_match_sync():
a = AsyncIP(seed=42, timezone="America/New_York", locale="de-DE")
s = SyncIP(seed=42, timezone="America/New_York", locale="de-DE")
assert a._default_context_kwargs() == s._default_context_kwargs()
@pytest.mark.unit
def test_async_resolve_headless_raises_clear_error_on_macos(monkeypatch):
"""The async launcher shares the same hidden-display limitation as the
sync launcher, so ``headless=True`` on macOS should fail the same way."""
import sys as _sys
monkeypatch.setattr(_sys, "platform", "darwin")
ip = AsyncIP(seed=42, headless=True)
with pytest.raises(RuntimeError, match="headless=True is not yet supported on macOS"):
ip._resolve_headless()

View file

@ -31,9 +31,29 @@ def test_archive_name_linux():
@pytest.mark.unit
def test_archive_name_unsupported_raises():
def test_archive_name_macos_arm64():
name = ARCHIVE_NAME("darwin", "arm64")
assert name.endswith(".tar.gz")
assert "macos-arm64" in name
@pytest.mark.unit
def test_archive_name_macos_x86_64():
name = ARCHIVE_NAME("darwin", "x86_64")
assert name.endswith(".tar.gz")
assert "macos-x86_64" in name
@pytest.mark.unit
def test_archive_name_unsupported_platform_raises():
with pytest.raises(NotImplementedError):
ARCHIVE_NAME("darwin", "arm64")
ARCHIVE_NAME("freebsd14", "x86_64")
@pytest.mark.unit
def test_archive_name_unsupported_arch_raises():
with pytest.raises(NotImplementedError):
ARCHIVE_NAME("darwin", "ppc64")
@pytest.mark.unit
@ -57,6 +77,10 @@ def test_binary_basename_format():
("linux", "AMD64", "linux-x86_64.tar.gz"), # odd but plausible
("Linux", "x86_64", "linux-x86_64.tar.gz"), # case-insensitive platform
("WIN32", "AMD64", "win-x86_64.zip"), # ALL CAPS platform
("darwin", "arm64", "macos-arm64.tar.gz"), # Apple Silicon
("darwin", "aarch64", "macos-arm64.tar.gz"), # alternate ARM64 spelling
("darwin", "AMD64", "macos-x86_64.tar.gz"), # Intel macOS
("Darwin", "x86_64", "macos-x86_64.tar.gz"), # case-insensitive platform
])
def test_archive_name_accepts_case_variations(platform_key, machine, expected_substring):
"""sys.platform / platform.machine() return inconsistent casing across
@ -87,7 +111,7 @@ def test_archive_name_arm64_not_yet_supported(machine):
@pytest.mark.unit
@pytest.mark.parametrize("platform_key", ["darwin", "freebsd", "cygwin", "openbsd"])
@pytest.mark.parametrize("platform_key", ["freebsd", "cygwin", "openbsd"])
def test_archive_name_rejects_unsupported_platforms(platform_key):
"""Same logic — non-Linux/non-Windows platforms must raise, not silently
pick one of the two."""
@ -104,7 +128,7 @@ def test_archive_name_rejects_unsupported_platforms(platform_key):
def test_binary_entry_rel_covers_every_supported_platform():
"""If ARCHIVE_NAME accepts a platform key, BINARY_ENTRY_REL must declare
where the executable lives inside the archive for it."""
for plat in ["win32", "linux"]:
for plat in ["win32", "linux", "darwin"]:
ARCHIVE_NAME(plat, "x86_64") # must not raise
assert plat in BINARY_ENTRY_REL, (
f"ARCHIVE_NAME accepts {plat!r} but BINARY_ENTRY_REL has no entry "
@ -114,10 +138,16 @@ def test_binary_entry_rel_covers_every_supported_platform():
@pytest.mark.unit
def test_binary_entry_rel_extension_matches_platform():
"""firefox.exe on Windows, plain `firefox` on Linux."""
"""firefox.exe on Windows, plain executable names on Linux and macOS."""
assert BINARY_ENTRY_REL["win32"].endswith(".exe")
assert not BINARY_ENTRY_REL["linux"].endswith(".exe")
assert BINARY_ENTRY_REL["linux"] == "firefox"
assert not BINARY_ENTRY_REL["darwin"].endswith(".exe")
@pytest.mark.unit
def test_macos_binary_entry_points_into_app_bundle():
assert BINARY_ENTRY_REL["darwin"] == "Firefox.app/Contents/MacOS/firefox"
# ---- RELEASE_URL_TEMPLATE shape ------------------------------------------- #
@ -176,7 +206,7 @@ def test_binary_basename_includes_upstream_version():
@pytest.mark.unit
@pytest.mark.parametrize("plat", ["win32", "linux"])
@pytest.mark.parametrize("plat", ["win32", "linux", "darwin"])
def test_archive_name_includes_upstream_version(plat):
"""Same desync guard, from the other direction."""
assert FIREFOX_UPSTREAM_VERSION in ARCHIVE_NAME(plat, "x86_64")

View file

@ -321,6 +321,8 @@ def test_ensure_binary_accepts_binary_mode_checksums(tmp_path, monkeypatch):
# Force the platform branch the test mocks:
monkeypatch.setattr("sys.platform", "win32")
import platform
monkeypatch.setattr(platform, "machine", lambda: "AMD64")
out = ensure_binary()
# No RuntimeError means the parser accepted the `*`-prefixed key.
assert out.exists()
@ -418,9 +420,9 @@ def test_github_token_none_when_unset(monkeypatch):
# Bonus coverage: unsupported platform raises NotImplementedError before any HTTP
@pytest.mark.unit
def test_ensure_binary_unsupported_platform_raises(monkeypatch):
monkeypatch.setattr("sys.platform", "darwin")
monkeypatch.setattr("sys.platform", "freebsd14")
import platform
monkeypatch.setattr(platform, "machine", lambda: "AMD64")
monkeypatch.setattr(platform, "machine", lambda: "x86_64")
with pytest.raises(NotImplementedError, match="unsupported platform"):
ensure_binary()
@ -542,6 +544,96 @@ def test_ensure_binary_missing_entry_after_extract_raises_linux(tmp_path, monkey
ensure_binary()
# ──────────────────────────────────────────────────────────────────────
# macOS platform tests — exercise the .app bundle path inside a tar.gz
# archive and both download/cache/error paths on darwin arm64.
# ──────────────────────────────────────────────────────────────────────
@pytest.mark.unit
@responses.activate
def test_ensure_binary_downloads_and_verifies_macos_arm64(tmp_path, monkeypatch):
"""macOS happy path: tar.gz download -> SHA256 check -> extract -> return
the Firefox executable inside the .app bundle."""
cache = tmp_path / "cache"
monkeypatch.setattr("invisible_playwright.download.cache_root", lambda: cache)
archive_path = tmp_path / "archive.tar.gz"
inner = "Firefox.app/Contents/MacOS/firefox"
archive_bytes = _make_targz(archive_path, inner, b"MACHO!")
archive_sha = hashlib.sha256(archive_bytes).hexdigest()
from invisible_playwright.constants import ARCHIVE_NAME
asset = ARCHIVE_NAME("darwin", "arm64")
url_archive = f"https://github.com/feder-cr/invisible_playwright/releases/download/{BINARY_VERSION}/{asset}"
url_sums = f"https://github.com/feder-cr/invisible_playwright/releases/download/{BINARY_VERSION}/checksums.txt"
responses.add(responses.GET, url_archive, body=archive_bytes, status=200,
content_type="application/gzip")
responses.add(responses.GET, url_sums,
body=f"{archive_sha} {asset}\n", status=200)
monkeypatch.setattr("sys.platform", "darwin")
import platform
monkeypatch.setattr(platform, "machine", lambda: "arm64")
path = ensure_binary()
assert Path(path).exists()
assert Path(path).as_posix().endswith("Firefox.app/Contents/MacOS/firefox")
@pytest.mark.unit
def test_ensure_binary_cache_hit_skips_http_macos(tmp_path, monkeypatch):
"""macOS cache hit short-circuits before any HTTP and returns the
executable nested inside the cached .app bundle."""
cache = tmp_path / "cache"
version_dir = cache / BINARY_VERSION / "Firefox.app" / "Contents" / "MacOS"
version_dir.mkdir(parents=True)
pre_cached = version_dir / "firefox"
pre_cached.write_text("cached-content")
monkeypatch.setattr("invisible_playwright.download.cache_root", lambda: cache)
monkeypatch.setattr("sys.platform", "darwin")
import platform
monkeypatch.setattr(platform, "machine", lambda: "arm64")
def _fail_get(*args, **kwargs):
raise AssertionError("HTTP must not be called on cache hit")
monkeypatch.setattr("invisible_playwright.download.requests.get", _fail_get)
path = ensure_binary()
assert path == pre_cached
assert path.read_text() == "cached-content"
@pytest.mark.unit
@responses.activate
def test_ensure_binary_missing_entry_after_extract_raises_macos(tmp_path, monkeypatch):
"""If the macOS archive extracts without the .app executable, raise
instead of returning a broken path."""
cache = tmp_path / "cache"
monkeypatch.setattr("invisible_playwright.download.cache_root", lambda: cache)
archive_path = tmp_path / "archive.tar.gz"
archive_bytes = _make_targz(archive_path, "Firefox.app/Contents/Info.plist", b"plist")
archive_sha = hashlib.sha256(archive_bytes).hexdigest()
from invisible_playwright.constants import ARCHIVE_NAME
asset = ARCHIVE_NAME("darwin", "arm64")
url_archive = f"https://github.com/feder-cr/invisible_playwright/releases/download/{BINARY_VERSION}/{asset}"
url_sums = f"https://github.com/feder-cr/invisible_playwright/releases/download/{BINARY_VERSION}/checksums.txt"
responses.add(responses.GET, url_archive, body=archive_bytes, status=200)
responses.add(responses.GET, url_sums, body=f"{archive_sha} {asset}\n", status=200)
monkeypatch.setattr("sys.platform", "darwin")
import platform
monkeypatch.setattr(platform, "machine", lambda: "arm64")
with pytest.raises(RuntimeError, match="binary not found after extraction"):
ensure_binary()
# ========================================================================== #
# _resolve_asset_url — public-repo direct URL vs private-repo API resolution
# ========================================================================== #

View file

@ -241,3 +241,16 @@ def test_e12_linux_resolve_headless_without_xvfb_raises_clear_error(monkeypatch)
with pytest.raises(RuntimeError, match="Xvfb"):
ip._resolve_headless()
assert ip._virtual_display is None
@pytest.mark.e2e
def test_e13_darwin_resolve_headless_raises_clear_error(monkeypatch):
"""E13: macOS can launch visibly, but ``headless=True`` should fail
early with a clear message until an invisible backend exists."""
import sys as _sys
monkeypatch.setattr(_sys, "platform", "darwin")
ip = InvisiblePlaywright(seed=42, headless=True)
with pytest.raises(RuntimeError, match="headless=True is not yet supported on macOS"):
ip._resolve_headless()
assert ip._virtual_display is None

View file

@ -7,9 +7,10 @@ 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``.
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.
Per scope: Windows/Linux hidden-display support plus the macOS guardrail.
We still cover the Linux dispatch branch because instantiating
``_LinuxVirtualDisplay`` does no I/O Xvfb is only spawned in
``start()``, which we never call.
"""
from __future__ import annotations
@ -51,19 +52,17 @@ def test_make_virtual_display_accepts_linux_variants(monkeypatch):
@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."""
"""macOS can launch visibly, but there is no hidden-display backend
for ``headless=True`` yet. The dispatcher should say that plainly."""
monkeypatch.setattr(headless.sys, "platform", "darwin")
with pytest.raises(RuntimeError, match="Windows and Linux only"):
with pytest.raises(RuntimeError, match="headless=True is not yet supported on macOS"):
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="headless=True supports Windows and Linux only"):
make_virtual_display()

View file

@ -82,16 +82,24 @@ def test_accept_language_underscore_normalized():
@pytest.mark.unit
def test_font_metrics_windows_returns_empty(monkeypatch):
# FM2: Windows never applies width-scale factors.
monkeypatch.setattr(sys, "platform", "win32")
assert _font_metrics_for_platform("Arial|1.0,Verdana|0.9,") == ""
@pytest.mark.unit
def test_font_metrics_empty_input_returns_empty():
# FM3: Empty input always returns "" regardless of platform.
assert _font_metrics_for_platform("") == ""
def test_font_metrics_windows_returns_empty(monkeypatch):
# FM2: Windows never applies width-scale factors.
monkeypatch.setattr(sys, "platform", "win32")
assert _font_metrics_for_platform("Arial|1.0,Verdana|0.9,") == ""
@pytest.mark.unit
def test_font_metrics_macos_returns_empty(monkeypatch):
# FM2b: macOS follows the same "no width-scale factors" path as
# Windows; only Linux needs the generic-family compensation block.
monkeypatch.setattr(sys, "platform", "darwin")
assert _font_metrics_for_platform("Arial|1.0,Verdana|0.9,") == ""
@pytest.mark.unit
def test_font_metrics_empty_input_returns_empty():
# FM3: Empty input always returns "" regardless of platform.
assert _font_metrics_for_platform("") == ""
# ──────────────────────────────────────────────────────────────────────
@ -143,13 +151,28 @@ def test_canvas_noise_mask_windows_uses_intel_path(monkeypatch):
@pytest.mark.unit
def test_webgl_extensions_cleared_on_windows(monkeypatch):
# WE2
monkeypatch.setattr(sys, "platform", "win32")
p = generate_profile(seed=42)
prefs = translate_profile_to_prefs(p)
assert prefs["zoom.stealth.webgl.extensions"] == ""
assert prefs["zoom.stealth.webgl2.extensions"] == ""
def test_webgl_extensions_cleared_on_windows(monkeypatch):
# WE2
monkeypatch.setattr(sys, "platform", "win32")
p = generate_profile(seed=42)
prefs = translate_profile_to_prefs(p)
assert prefs["zoom.stealth.webgl.extensions"] == ""
assert prefs["zoom.stealth.webgl2.extensions"] == ""
@pytest.mark.unit
def test_macos_uses_non_linux_webgl_defaults(monkeypatch):
# WE2b / PG2b: macOS follows the non-Linux branch for renderer/vendor
# and extension exposure until native macOS tuning lands.
monkeypatch.setattr(sys, "platform", "darwin")
p = generate_profile(seed=42, pin={"webgl.msaa_samples": 8})
prefs = translate_profile_to_prefs(p)
assert prefs["zoom.stealth.webgl.renderer"] == ""
assert prefs["zoom.stealth.webgl.vendor"] == ""
assert prefs["zoom.stealth.webgl.extensions"] == ""
assert prefs["zoom.stealth.webgl2.extensions"] == ""
assert prefs["zoom.stealth.webgl.msaa"] == 4
assert prefs["webgl.msaa-samples"] == 4
# ──────────────────────────────────────────────────────────────────────