mirror of
https://github.com/daijro/camoufox.git
synced 2026-09-10 08:00:52 +00:00
Fixes the new_page() hang from #666, and restores the pythonlib/ + settings/ halves of #637-#647 that were dropped when those PRs were consolidated into #666 (that PR only carried patches/ + additions/, so these never actually landed). ## new_page() hangs when window.outer* is spoofed (#666) The outer-size hijack in browser-init.patch pinned the chrome documentElement to the spoofed size. That caps .browserStack, which caps the content viewport, so the content window can never reach the size Juggler asks for in updateViewportSize() -- and awaitViewportDimensions awaits exact equality with no timeout, so it deadlocks rather than erroring. The second new_page() hung forever and took the context with it. The pin was never load-bearing: GetOuterWidth/GetOuterHeight already consult MaskConfig unconditionally (fingerprint-injection.patch), so window.outerWidth is spoofed in C++ regardless of the real chrome window size. Resizing is enough. Measured on the official v152.0.4-beta.26 build (headless): config before after none pass pass inner pass pass outer HANG pass both HANG pass (iw:360 ih:740 ow:360 oh:800 -- exact) This corrects the diagnosis in #666, which blamed the inner+outer combination and the `!(outerWidth || outerHeight)` guard. outer* ALONE is sufficient to hang, and dropping inner* does not help, so that guard is not the culprit. Also fixed driver-side: Playwright's implicit 1280x720 viewport is what asks for the impossible size, so the driver now defaults to no_viewport when the config spoofs any window dimension. That fixes the hang on already-released builds without a rebuild. An explicit viewport=/no_viewport= from the caller wins. ## WebRTC ICE prefs (#538) #666 merged the C++ half of the WebRTC fix but not the prefs, so the shipped build still has no_host=true and none of the proxy_only prefs. proxy_only_if_behind_proxy is the pref that actually stops the real-IP leak: it prevents a UDP STUN request routing around a TCP proxy. no_host=false keeps the stock two-candidate shape, which obfuscate_host_addresses makes leak-free. ## Also restored from the consolidation - fix(proxy): dom.security.https_first rewrote http:// before the launch-arg proxy filter saw it, breaking CONNECT-only proxies (#638). - fix(stealth): speech-voice spoofing + stop leaking host voices (#646). - fix(stealth): clamp inner <= outer <= avail <= screen; BrowserForge can emit impossible geometries that leak as tells (#647). Refs: https://github.com/daijro/camoufox/pull/666 Refs: https://github.com/daijro/camoufox/issues/538
210 lines
7.6 KiB
Python
210 lines
7.6 KiB
Python
import asyncio
|
|
import json as _json
|
|
import urllib.request
|
|
from functools import partial
|
|
from typing import Any, Dict, List, Optional, Union, overload
|
|
from urllib.parse import urlparse
|
|
|
|
from playwright.async_api import (
|
|
Browser,
|
|
BrowserContext,
|
|
Playwright,
|
|
PlaywrightContextManager,
|
|
)
|
|
from typing_extensions import Literal
|
|
|
|
from camoufox.virtdisplay import VirtualDisplay
|
|
|
|
from .fingerprints import generate_context_fingerprint
|
|
from .utils import (
|
|
async_attach_vd,
|
|
attach_no_viewport_default,
|
|
launch_options,
|
|
spoofs_window_dimensions,
|
|
)
|
|
|
|
|
|
class AsyncCamoufox(PlaywrightContextManager):
|
|
"""
|
|
Wrapper around playwright.async_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
|
|
|
|
async def __aenter__(self) -> Union[Browser, BrowserContext]:
|
|
_playwright = await super().__aenter__()
|
|
try:
|
|
self.browser = await AsyncNewBrowser(_playwright, **self.launch_options)
|
|
except BaseException as e:
|
|
# Any launch failure (InvalidProxy, missing browser, bad options, ...)
|
|
# must tear down the playwright session started above so the driver
|
|
# process/connection is not leaked.
|
|
await super().__aexit__(type(e), e, e.__traceback__)
|
|
raise
|
|
return self.browser
|
|
|
|
async def __aexit__(self, *args: Any):
|
|
# Run the base teardown even if browser.close() raises (e.g. the browser
|
|
# process already crashed), so the playwright driver/connection is not leaked.
|
|
try:
|
|
if self.browser:
|
|
await self.browser.close()
|
|
finally:
|
|
await super().__aexit__(*args)
|
|
|
|
|
|
@overload
|
|
async def AsyncNewBrowser(
|
|
playwright: Playwright,
|
|
*,
|
|
from_options: Optional[Dict[str, Any]] = None,
|
|
persistent_context: Literal[False] = False,
|
|
**kwargs,
|
|
) -> Browser: ...
|
|
|
|
|
|
@overload
|
|
async def AsyncNewBrowser(
|
|
playwright: Playwright,
|
|
*,
|
|
from_options: Optional[Dict[str, Any]] = None,
|
|
persistent_context: Literal[True],
|
|
**kwargs,
|
|
) -> BrowserContext: ...
|
|
|
|
|
|
async def AsyncNewBrowser(
|
|
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:
|
|
from_options = await asyncio.get_event_loop().run_in_executor(
|
|
None,
|
|
partial(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)
|
|
|
|
# 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}
|
|
context = await playwright.firefox.launch_persistent_context(**from_options)
|
|
return await async_attach_vd(context, virtual_display)
|
|
|
|
# Browser
|
|
browser = await playwright.firefox.launch(**from_options)
|
|
if no_viewport_default:
|
|
attach_no_viewport_default(browser)
|
|
return await async_attach_vd(browser, virtual_display)
|
|
|
|
|
|
def _proxy_url_with_creds(proxy: Dict[str, str]) -> str:
|
|
"""Builds a proxy URL string with embedded credentials."""
|
|
parsed = urlparse(proxy.get("server", ""))
|
|
user = proxy.get("username", "")
|
|
pwd = proxy.get("password", "")
|
|
if user and pwd:
|
|
return f"{parsed.scheme}://{user}:{pwd}@{parsed.netloc}"
|
|
return proxy.get("server", "")
|
|
|
|
|
|
async def _resolve_proxy_geo(proxy: Dict[str, str]) -> Dict[str, Optional[str]]:
|
|
"""Queries ip-api.com through the proxy for the exit IP and timezone."""
|
|
proxy_url = _proxy_url_with_creds(proxy)
|
|
|
|
def _fetch() -> Dict[str, Optional[str]]:
|
|
handler = urllib.request.ProxyHandler({"http": proxy_url, "https": proxy_url})
|
|
opener = urllib.request.build_opener(handler)
|
|
try:
|
|
with opener.open("http://ip-api.com/json?fields=query,timezone", timeout=10) as resp:
|
|
data = _json.loads(resp.read())
|
|
return {"ip": data.get("query") or None, "timezone": data.get("timezone") or None}
|
|
except Exception:
|
|
return {"ip": None, "timezone": None}
|
|
|
|
return await asyncio.get_event_loop().run_in_executor(None, _fetch)
|
|
|
|
|
|
async def AsyncNewContext(
|
|
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 real fingerprint preset (navigator, screen, WebGL, fonts, etc.)
|
|
with unique seeds for audio, canvas, and font spacing noise. All values are applied
|
|
via addInitScript so they self-destruct before page scripts can detect them.
|
|
|
|
Parameters:
|
|
browser: A Browser instance from AsyncNewBrowser or AsyncCamoufox.
|
|
preset: A specific fingerprint preset dict to use. If None, picks randomly.
|
|
os: Target OS for preset selection ("windows", "macos", "linux").
|
|
ff_version: Firefox version string for UA patching.
|
|
webrtc_ip: IPv4 address to spoof for WebRTC ICE candidates.
|
|
proxy: Per-context proxy (Playwright format: {"server": "...", "username": "...", "password": "..."}).
|
|
geolocation: Per-context geolocation ({"latitude": float, "longitude": float}).
|
|
**context_kwargs: Additional Playwright new_context() options.
|
|
"""
|
|
# 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):
|
|
geo = await _resolve_proxy_geo(proxy)
|
|
if not webrtc_ip:
|
|
webrtc_ip = geo["ip"]
|
|
if "timezone_id" not in context_kwargs and geo["timezone"]:
|
|
context_kwargs["timezone_id"] = geo["timezone"]
|
|
|
|
fp = await asyncio.get_event_loop().run_in_executor(
|
|
None,
|
|
lambda: 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 = await browser.new_context(**opts)
|
|
await context.add_init_script(fp['init_script'])
|
|
return context
|