Files
camoufox/pythonlib/camoufox/sync_api.py
T
Jake WriterandClaude Opus 5.5 a3dbe40d1a fix: stop generating a canvas seed, and drop config keys nothing reads
The browser has not noised the canvas since #528, and no patch reads
canvas:seed (#721). The launcher still drew one on every launch and sent it
through CAMOU_CONFIG, and NewContext called a setCanvasSeed that does not
exist. They no longer do.

For users this changes nothing on any browser since #528: the value was
ignored. A config that still passes canvas:seed gets the usual "Skipping
unknown patch" notice instead of silence. On a browser from before #528, the
launcher no longer turns canvas noise on, which is the behaviour #528 chose.

The same audit found more keys declared in settings/properties.json that no
patch or Juggler file reads, so setting them did nothing:
- canvas:aaOffset, canvas:aaCapOffset
- memorysaver, pdfViewerEnabled, webrtc:localipv4/6
- navigator.onLine, navigator.cookieEnabled, navigator.languages
- navigator.appCodeName, appName, product, productSub. Firefox reports these
  constants itself, so fpgen.yml no longer maps them.
- webGl:parameters:blockIfNotDefined and its WebGL2 twin

test_config_schema now checks this direction too: every declared key must be
read by the browser, unless it is listed with a reason. Three are listed:
locale:script and navigator.doNotTrack, which the launcher applies itself,
and navigator.buildID (#780).

The build-tester grading followed the same wrong premise. It tracked canvas
collisions as an unfixed per-context leak. A canvas that is rendered rather
than noised follows the fonts and GPU, as it does on real machines, so canvas
collisions are now counted with the other device-level values. The tribal rule
that recorded it as an open question is now a settled one,
canvas-is-not-noised, with an automated check.

Closes #721.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 15:22:28 -06:00

211 lines
8.1 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_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)
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 real fingerprint preset
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 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": "..."}).
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.
"""
# 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