mirror of
https://github.com/daijro/camoufox.git
synced 2026-10-05 08:00:21 +00:00
#798 left RDM on for isMobile, as Playwright's Juggler does. RDM matches no real browser: Firefox for Android never runs it, and under touch emulation it drops a mouse click's pointer events, which no device does. Camoufox only has desktop identities, so an is_mobile context was a desktop UA, platform, fonts and GPU with devtools' mobile mode on top. Juggler now keeps inRDMPane off for every page, is_mobile included, and the isMobile plumbing #798 added is gone again. viewport-no-rdm requires is_mobile=True to keep the platform's scrollbars too. Both launchers warn instead (warnings.yml is_mobile): on new_page() / new_context(is_mobile=True) of a Camoufox browser, and on is_mobile passed to launch_options() for a persistent context. has_touch, device_scale_factor and viewport keep working without RDM. Upstream playwright-python skips its isMobile tests on Firefox in 1.61-1.63, so no skiplist entries are needed. On beta.31 with this Juggler in omni.ja, viewport-no-rdm passes: 12/12 px of scrollbar with no viewport, with a viewport, and with is_mobile, and a has_touch click fires pointerdown and pointerup. pythonlib 411 passed; typescript 584 passed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
217 lines
8.4 KiB
Python
217 lines
8.4 KiB
Python
from typing import Any, Dict, Optional, Tuple, Union, overload
|
|
|
|
from playwright.sync_api import (
|
|
Browser,
|
|
BrowserContext,
|
|
Playwright,
|
|
PlaywrightContextManager,
|
|
)
|
|
from typing_extensions import Literal
|
|
|
|
from camoufox.virtdisplay import VirtualDisplay
|
|
|
|
from .fingerprints import generate_context_fingerprint
|
|
from .ip import Proxy, proxy_exit_geo
|
|
from .utils import (
|
|
attach_no_viewport_default,
|
|
attach_desktop_only_warning,
|
|
attach_stock_media_defaults,
|
|
launch_options,
|
|
STOCK_MEDIA_DEFAULTS,
|
|
spoofs_window_dimensions,
|
|
sync_attach_vd,
|
|
)
|
|
|
|
|
|
class Camoufox(PlaywrightContextManager):
|
|
"""
|
|
Wrapper around playwright.sync_api.PlaywrightContextManager that automatically
|
|
launches a browser and closes it when the context manager is exited.
|
|
"""
|
|
|
|
def __init__(self, **launch_options):
|
|
super().__init__()
|
|
self.launch_options = launch_options
|
|
self.browser: Optional[Union[Browser, BrowserContext]] = None
|
|
|
|
def __enter__(self) -> Union[Browser, BrowserContext]:
|
|
super().__enter__()
|
|
try:
|
|
self.browser = NewBrowser(self._playwright, **self.launch_options)
|
|
except BaseException as e:
|
|
# Any launch failure (InvalidProxy, missing browser, bad options, ...)
|
|
# must tear down the playwright session started above. Leaking it leaves
|
|
# the sync API's event loop in a "running" state, so every later sync
|
|
# Camoufox/Playwright start in this thread fails with "Sync API inside
|
|
# the asyncio loop" until the process restarts (#82).
|
|
super().__exit__(type(e), e, e.__traceback__)
|
|
raise
|
|
return self.browser
|
|
|
|
def __exit__(self, *args: Any):
|
|
# Run the base teardown even if browser.close() raises (e.g. the browser
|
|
# process already crashed). Skipping it leaks the sync API's event loop in a
|
|
# "running" state, so every later sync Camoufox/Playwright start in the same
|
|
# thread fails with "Sync API inside the asyncio loop" until process restart.
|
|
try:
|
|
if self.browser:
|
|
self.browser.close()
|
|
finally:
|
|
super().__exit__(*args)
|
|
|
|
|
|
@overload
|
|
def NewBrowser(
|
|
playwright: Playwright,
|
|
*,
|
|
from_options: Optional[Dict[str, Any]] = None,
|
|
persistent_context: Literal[False] = False,
|
|
**kwargs,
|
|
) -> Browser: ...
|
|
|
|
|
|
@overload
|
|
def NewBrowser(
|
|
playwright: Playwright,
|
|
*,
|
|
from_options: Optional[Dict[str, Any]] = None,
|
|
persistent_context: Literal[True],
|
|
**kwargs,
|
|
) -> BrowserContext: ...
|
|
|
|
|
|
def NewBrowser(
|
|
playwright: Playwright,
|
|
*,
|
|
headless: Optional[Union[bool, Literal['virtual']]] = None,
|
|
from_options: Optional[Dict[str, Any]] = None,
|
|
persistent_context: bool = False,
|
|
debug: Optional[bool] = None,
|
|
**kwargs,
|
|
) -> Union[Browser, BrowserContext]:
|
|
"""
|
|
Launches a new browser instance for Camoufox given a set of launch options.
|
|
|
|
Parameters:
|
|
from_options (Dict[str, Any]):
|
|
A set of launch options generated by `launch_options()` to use
|
|
persistent_context (bool):
|
|
Whether to use a persistent context.
|
|
**kwargs:
|
|
All other keyword arugments passed to `launch_options()`.
|
|
"""
|
|
if headless == 'virtual':
|
|
virtual_display = VirtualDisplay(debug=debug)
|
|
kwargs['virtual_display'] = virtual_display.get()
|
|
headless = False
|
|
else:
|
|
virtual_display = None
|
|
|
|
if not from_options:
|
|
# Opt-in (2026-09-17). Pinning keeps the identity's core count by
|
|
# constraining the browser to that many cores; it costs real CPU, needs
|
|
# a launch lock, and does nothing on macOS. What it defends against is a
|
|
# page timing N parallel workers, which is expensive and noisy on a busy
|
|
# machine. Off, the host's own snapped count is reported, so reported
|
|
# and measurable still agree -- the identity just loses that one draw.
|
|
kwargs.setdefault('pin_cpu_cores', False)
|
|
from_options = launch_options(headless=headless, debug=debug, **kwargs)
|
|
|
|
# Playwright's default viewport deadlocks Juggler when the window is spoofed
|
|
# to a different size (daijro/camoufox#666), so default to no_viewport.
|
|
no_viewport_default = spoofs_window_dimensions(from_options)
|
|
|
|
# Pin the driver (and so the browser it is about to spawn) to as many
|
|
# cores as the identity reports, so measurable parallelism matches
|
|
# navigator.hardwareConcurrency; the driver gets its cores back afterwards.
|
|
from . import cpu_affinity
|
|
from .utils import driver_pid, pinned_core_count
|
|
|
|
pin_to = pinned_core_count(from_options)
|
|
pid = driver_pid(playwright) if pin_to else None
|
|
previous = cpu_affinity.pin(pid, pin_to) if pid else None
|
|
try:
|
|
# Persistent context
|
|
if persistent_context:
|
|
if no_viewport_default and not ('viewport' in from_options or 'no_viewport' in from_options):
|
|
from_options = {**from_options, 'no_viewport': True}
|
|
# The persistent context is created by the launch itself, so its media
|
|
# features come from these options rather than from new_context().
|
|
from_options = {
|
|
**{k: v for k, v in STOCK_MEDIA_DEFAULTS.items() if k not in from_options},
|
|
**from_options,
|
|
}
|
|
context = playwright.firefox.launch_persistent_context(**from_options)
|
|
return sync_attach_vd(context, virtual_display)
|
|
|
|
# Browser
|
|
browser = playwright.firefox.launch(**from_options)
|
|
if no_viewport_default:
|
|
attach_no_viewport_default(browser)
|
|
attach_stock_media_defaults(browser)
|
|
attach_desktop_only_warning(browser)
|
|
return sync_attach_vd(browser, virtual_display)
|
|
finally:
|
|
if pid:
|
|
cpu_affinity.restore(pid, previous)
|
|
|
|
|
|
def _resolve_proxy_geo(proxy: Dict[str, str]) -> Tuple[str, str]:
|
|
"""The proxy's exit IP and timezone."""
|
|
return proxy_exit_geo(Proxy(**proxy).as_string())
|
|
|
|
|
|
def NewContext(
|
|
browser: Browser,
|
|
*,
|
|
preset: Optional[Dict[str, Any]] = None,
|
|
os: Optional[str] = None,
|
|
ff_version: Optional[str] = None,
|
|
webrtc_ip: Optional[str] = None,
|
|
proxy: Optional[Dict[str, str]] = None,
|
|
geolocation: Optional[Dict[str, float]] = None,
|
|
**context_kwargs: Any,
|
|
) -> BrowserContext:
|
|
"""
|
|
Creates a new browser context with a unique fingerprint identity.
|
|
|
|
Each context gets its own identity (navigator, screen, WebGL, fonts, voices),
|
|
drawn by fpgen unless a preset is given, with its own audio noise seed. All values are applied
|
|
via addInitScript so they self-destruct before page scripts can detect them.
|
|
|
|
Parameters:
|
|
browser: A Browser instance from NewBrowser or Camoufox.
|
|
preset: A fingerprint preset dict to use. If None, fpgen draws a new identity.
|
|
os: Target OS for the drawn identity ("windows", "macos", "linux").
|
|
ff_version: Firefox major version to claim in the UA. Defaults to the browser's own.
|
|
webrtc_ip: IPv4 or IPv6 address to spoof for WebRTC ICE candidates.
|
|
proxy: Per-context proxy (Playwright format: {"server": "...", "username": "...", "password": "..."}).
|
|
Unless webrtc_ip and timezone_id are both given, they are looked up from the
|
|
proxy's exit IP; InvalidIP is raised if that lookup fails.
|
|
geolocation: Per-context geolocation ({"latitude": float, "longitude": float}).
|
|
**context_kwargs: Additional Playwright new_context() options.
|
|
"""
|
|
# The drawn UA carries fpgen's Firefox version, which must not disagree with
|
|
# the browser the page is actually talking to.
|
|
ff_version = ff_version or browser.version.split('.', 1)[0]
|
|
|
|
# Auto-derive WebRTC IP and timezone from proxy's exit IP when not explicitly provided
|
|
if proxy and (not webrtc_ip or "timezone_id" not in context_kwargs):
|
|
exit_ip, timezone = _resolve_proxy_geo(proxy)
|
|
webrtc_ip = webrtc_ip or exit_ip
|
|
context_kwargs.setdefault("timezone_id", timezone)
|
|
|
|
fp = generate_context_fingerprint(preset=preset, os=os, ff_version=ff_version, webrtc_ip=webrtc_ip)
|
|
|
|
# Merge generated context options with user overrides (user wins)
|
|
opts: Dict[str, Any] = {**fp['context_options'], **context_kwargs}
|
|
if proxy:
|
|
opts['proxy'] = proxy
|
|
if geolocation:
|
|
opts['geolocation'] = geolocation
|
|
opts.setdefault('permissions', ['geolocation'])
|
|
|
|
context = browser.new_context(**opts)
|
|
context.add_init_script(fp['init_script'])
|
|
return context
|