Files
camoufox/pythonlib/camoufox/utils.py
T
Jake WriterandClaude Opus 5 0e1f9a816d fix(python): bound headful geometry again after the get_screen_cons flip
PR #315 corrects get_screen_cons()'s inverted guard (`headless is False` ->
`headless is True`), which is right on its own. But the call site passes
`headless or has_display(env)`, folding two separate questions into one
boolean, so with the corrected guard a headful run on a real display now
reads as headless and the display bound is skipped:

  headless=False, has_display=True  -> arg=True  -> None   (want Screen)
  headless=False, has_display=False -> arg=False -> Screen (want None)

That drops the monitor bound for every ordinary headful launch, which is
the constraint 2266f27 added for #499 -- a 1366x768 laptop goes back to
being handed a 2560x1440 fingerprint and a window drawn past the edge of
the screen.

Pass `headless` alone and gate on has_display() separately.
largest_display() already returns None when there is nothing to probe, so
the no-display case still yields None.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQgHHGRXNp29jr4xQjK7iv
2026-09-05 14:26:37 -06:00

1051 lines
41 KiB
Python

import os
import sys
from functools import wraps
from os import environ
from os.path import abspath
from pathlib import Path
from pprint import pprint
from random import randint
from typing import Any, Dict, List, Literal, Optional, Tuple, Union
import numpy as np
import orjson
from browserforge.fingerprints import Fingerprint, Screen
from typing_extensions import TypeAlias
from ua_parser import user_agent_parser
from .addons import DefaultAddons, add_default_addons, confirm_paths
from .display import has_display, largest_display
from .exceptions import (
InvalidOS,
InvalidPropertyType,
NonFirefoxFingerprint,
)
from .fingerprints import from_browserforge, from_preset, generate_fingerprint, get_random_preset, _generate_random_font_subset, _generate_random_voice_subset, fix_navigator_arch, fix_screen_no_taskbar, clamp_screen_to_display, clamp_window_dimensions, clamp_window_position, raise_screen_to_modern_floor, sample_webgl_for_screen, set_media_devices_defaults
from .geolocation import geoip_allowed, get_geolocation
from .ip import Proxy, public_ip, valid_ipv4, valid_ipv6
from .locales import handle_locales
import warnings
from .pkgman import (
INSTALL_DIR,
OS_NAME,
Version,
effective_version_min,
ensure_browser_profile_dir,
get_path,
installed_verstr,
launch_path,
)
from .virtdisplay import VirtualDisplay
from ._warnings import LeakWarning
from .webgl import sample_webgl
ListOrString: TypeAlias = Union[Tuple[str, ...], List[str], str]
# Camoufox preferences to cache previous pages and requests
CACHE_PREFS = {
'browser.sessionhistory.max_entries': 10,
'browser.sessionhistory.max_total_viewers': -1,
'browser.cache.memory.enable': True,
'browser.cache.disk_cache_ssl': True,
'browser.cache.disk.smart_size.enabled': True,
}
def _generate_fontconfig(fontconfig_path: str, path: Optional[Path] = None) -> str:
"""
Generates a runtime fontconfig that resolves bundled font paths absolutely.
The bundled fonts.conf uses prefix="cwd" relative paths which break when
Playwright's working directory differs from the browser install directory.
Writes a patched copy to the platform cache dir (deterministic, only
regenerated when content changes). This must not live inside the versioned
browser bundle: the bundle is commonly baked into an image as root and run
as a non-root user, so it is read-only at launch time.
"""
import hashlib
# Beside the caller's own binary when they supplied one; see get_env_vars.
fonts_dir = str(path.parent / "fonts") if path else get_path("fonts")
fonts_conf_src = os.path.join(fontconfig_path, "fonts.conf")
with open(fonts_conf_src, 'r') as f:
conf_content = f.read()
conf_content = conf_content.replace(
'<dir prefix="cwd">fonts</dir>',
f'<dir>{fonts_dir}</dir>',
)
# INSTALL_DIR is platformdirs' user_cache_dir("camoufox"); see pkgman.
cache_dir = str(INSTALL_DIR / 'fontconfig')
os.makedirs(cache_dir, exist_ok=True)
content_hash = hashlib.sha256(conf_content.encode()).hexdigest()[:12]
runtime_conf = os.path.join(cache_dir, f'fonts-{content_hash}.conf')
if not os.path.exists(runtime_conf):
with open(runtime_conf, 'w') as f:
f.write(conf_content)
return runtime_conf
def warn_if_executable_predates_playwright(path: Optional[Path]) -> None:
"""Warn when a caller's own binary is older than their Playwright needs.
A managed install below the floor is simply upgraded (pkgman resolves it),
but `executable_path` deliberately bypasses that -- the caller supplied the
binary, so we neither replace it nor download another. That leaves the one
pairing nothing checks: an old build driven by Playwright >= 1.61, which
sends viewport fields the older Juggler schema rejects.
This warns rather than raises, because the pairing is not always fatal.
Camoufox defaults to no_viewport when it spoofs window dimensions
(sync_api), and Playwright then never sends Browser.setDefaultViewport --
so the default path works on an old build. It breaks only when a viewport
is set explicitly, and then the error is a bare "Protocol error
(Browser.setDefaultViewport)" with nothing pointing at the real cause.
Refusing to launch would break setups that currently work.
A build with no version.json beside it -- an unpackaged objdir build, say --
tells us nothing, so it is left alone.
"""
if path is None:
return
try:
installed = Version.from_path(Path(path).parent)
except (FileNotFoundError, KeyError, ValueError):
return
required = effective_version_min()
if installed >= required:
return
warnings.warn(
f"The Camoufox build at {path} is {installed.build}, but Playwright "
f"{_resolved_playwright_version_str()} needs at least {required.build}. "
"Contexts created with an explicit viewport will fail with "
'"Protocol error (Browser.setDefaultViewport)". Update the build, or pin '
"playwright<1.61.",
RuntimeWarning,
stacklevel=3,
)
def _resolved_playwright_version_str() -> str:
from importlib.metadata import version
try:
return version('playwright')
except Exception:
return 'the installed version'
def get_env_vars(
config_map: Dict[str, str],
user_agent_os: str,
path: Optional[Path] = None,
) -> Dict[str, Union[str, float, bool]]:
"""
Gets a dictionary of environment variables for Camoufox.
`path` is the caller's own executable, when they supplied one. The bundled
fontconfig is read from beside that binary rather than from the managed
install, the same way _load_properties() already treats properties.json:
a caller running their own build should not be resolved against, or made
to download, a different one.
"""
env_vars: Dict[str, Union[str, float, bool]] = {}
try:
updated_config_data = orjson.dumps(config_map)
except orjson.JSONEncodeError as e:
print(f"Error updating config: {e}")
sys.exit(1)
# Split the config into chunks
chunk_size = 2047 if OS_NAME == 'win' else 32767
config_str = updated_config_data.decode('utf-8')
for i in range(0, len(config_str), chunk_size):
chunk = config_str[i : i + chunk_size]
env_name = f"CAMOU_CONFIG_{(i // chunk_size) + 1}"
try:
env_vars[env_name] = chunk
except Exception as e:
print(f"Error setting {env_name}: {e}")
sys.exit(1)
if OS_NAME == 'lin':
# https://github.com/coryking/camoufox/commit/f21eeb2850a74cc104fb57e17e0a2fa27b7a2a28
# Thanks @coryking
# the user_agent_os is either 'lin', 'mac', or 'win' but our fontconfig directory is 'linux', 'macos', or 'windows'
directory_map = {
'lin': 'linux',
'mac': 'macos',
'win': 'windows',
}
os_dir = directory_map.get(user_agent_os, user_agent_os)
# v150+ uses "fontconfig/" (matching the Go launcher); older bundles shipped "fontconfigs/".
def _bundle_path(*parts: str) -> str:
if path:
return str(path.parent.joinpath(*parts))
return get_path(os.path.join(*parts))
fontconfig_path = _bundle_path("fontconfig", os_dir)
if not os.path.exists(os.path.join(fontconfig_path, "fonts.conf")):
fontconfig_path = _bundle_path("fontconfigs", os_dir)
# assert that fonts.conf exists in the directory
if not os.path.exists(os.path.join(fontconfig_path, "fonts.conf")):
# puke violently if fonts.conf doesn't exist!!
raise FileNotFoundError(
f"fonts.conf not found in {fontconfig_path}! Something ain't right with your camoufox bundle."
)
env_vars['FONTCONFIG_FILE'] = _generate_fontconfig(fontconfig_path, path=path)
return env_vars
def _load_properties(path: Optional[Path] = None) -> Dict[str, str]:
"""
Loads the properties.json file.
"""
if path:
prop_file = str(path.parent / "properties.json")
else:
prop_file = get_path("properties.json")
with open(prop_file, "rb") as f:
prop_dict = orjson.loads(f.read())
return {prop['property']: prop['type'] for prop in prop_dict}
def validate_config(config_map: Dict[str, str], path: Optional[Path] = None) -> None:
"""
Validates the config map.
"""
property_types = _load_properties(path=path)
for key, value in config_map.items():
expected_type = property_types.get(key)
if not expected_type:
print(f'Skipping unknown patch {key} : {value}')
continue # Property not supported by this browser version; skip silently
if not validate_type(value, expected_type):
raise InvalidPropertyType(
f"Invalid type for property {key}. Expected {expected_type}, got {type(value).__name__}"
)
if key == 'voices':
validate_voices(value)
def validate_type(value: Any, expected_type: str) -> bool:
"""
Validates the type of the value.
"""
if expected_type == "str":
return isinstance(value, str)
elif expected_type == "int":
return isinstance(value, int) or (isinstance(value, float) and value.is_integer())
elif expected_type == "uint":
return (
isinstance(value, int) or (isinstance(value, float) and value.is_integer())
) and value >= 0
elif expected_type == "double":
return isinstance(value, (float, int))
elif expected_type == "bool":
return isinstance(value, bool)
elif expected_type == "array":
return isinstance(value, list)
elif expected_type == "dict":
return isinstance(value, dict)
else:
return False
# The five fields MaskConfig::MVoices() requires of every `voices` entry. It
# skips anything missing one of them, so a bare "Name:lang:type" string or a
# half-filled object registers nothing -- and a voice list that registers
# nothing leaves the host's native voices exposed. Reject the bad shape here,
# before launch, instead of letting it degrade silently in the browser (#731).
VOICE_FIELDS: Tuple[str, ...] = ('lang', 'name', 'voiceUri', 'isDefault', 'isLocalService')
def validate_voices(voices: Any) -> None:
"""
Validates that every `voices` entry is a complete voice object.
"""
if not isinstance(voices, list):
raise InvalidPropertyType(
f"Invalid type for property voices. Expected array, got {type(voices).__name__}"
)
for index, voice in enumerate(voices):
if not isinstance(voice, dict):
raise InvalidPropertyType(
f"Invalid voices[{index}]: expected an object with "
f"{{{', '.join(VOICE_FIELDS)}}}, got {type(voice).__name__} "
f"({voice!r}). Camoufox needs full voice objects, not names."
)
missing = [field for field in VOICE_FIELDS if field not in voice]
if missing:
raise InvalidPropertyType(
f"Invalid voices[{index}]: missing {', '.join(missing)}. "
f"Every voice needs {{{', '.join(VOICE_FIELDS)}}}."
)
def get_target_os(config: Dict[str, Any]) -> Literal['mac', 'win', 'lin']:
"""
Gets the OS from the config if the user agent is set,
otherwise returns the OS of the current system.
"""
if config.get("navigator.userAgent"):
return determine_ua_os(config["navigator.userAgent"])
return OS_NAME
def determine_ua_os(user_agent: str) -> Literal['mac', 'win', 'lin']:
"""
Determines the OS from the user agent string.
"""
parsed_ua = user_agent_parser.ParseOS(user_agent).get('family')
if not parsed_ua:
raise ValueError("Could not determine OS from user agent")
if parsed_ua.startswith("Mac"):
return "mac"
if parsed_ua.startswith("Windows"):
return "win"
return "lin"
def get_screen_cons(headless: Optional[bool] = None) -> Optional[Screen]:
"""
Determines a sane viewport size for Camoufox if being ran in headful mode.
Bounds are CSS pixels, the unit Firefox lays its windows out in -- see
camoufox.display for why that differs from the monitor's physical size.
"""
if headless is True:
return None # Skip if headless
display = largest_display()
if display is None:
return None # Skip if the display can't be probed
return Screen(max_width=display.width, max_height=display.height)
def update_fonts(config: Dict[str, Any], target_os: str) -> None:
"""
Updates the fonts for the target OS.
"""
with open(os.path.join(os.path.dirname(__file__), "fonts.json"), "rb") as f:
fonts = orjson.loads(f.read())[target_os]
# Merge with existing fonts
if 'fonts' in config:
config['fonts'] = np.unique(fonts + config['fonts']).tolist()
else:
config['fonts'] = fonts
def check_custom_fingerprint(fingerprint: Fingerprint) -> None:
"""
Asserts that the passed BrowserForge fingerprint is a valid Firefox fingerprint.
and warns the user that passing their own fingerprint is not recommended.
"""
# Check what the browser is
browser_name = user_agent_parser.ParseUserAgent(fingerprint.navigator.userAgent).get(
'family', 'Non-Firefox'
)
if browser_name != 'Firefox':
raise NonFirefoxFingerprint(
f'"{browser_name}" fingerprints are not supported in Camoufox. '
'Using fingerprints from a browser other than Firefox WILL lead to detection. '
'If this is intentional, pass `i_know_what_im_doing=True`.'
)
LeakWarning.warn('custom_fingerprint', False)
def check_valid_os(os: ListOrString) -> None:
"""
Checks if the target OS is valid.
"""
if not isinstance(os, str):
for os_name in os:
check_valid_os(os_name)
return
# Assert that the OS is lowercase
if not os.islower():
raise InvalidOS(f"OS values must be lowercase: '{os}'")
# Assert that the OS is supported by Camoufox
if os not in ('windows', 'macos', 'linux'):
raise InvalidOS(f"Camoufox does not support the OS: '{os}'")
def _clean_locals(data: Dict[str, Any]) -> Dict[str, Any]:
"""
Gets the launch options from the locals of the function.
"""
del data['playwright']
del data['persistent_context']
return data
def merge_into(target: Dict[str, Any], source: Dict[str, Any]) -> None:
"""
Merges new keys/values from the source dictionary into the target dictionary.
Given that the key does not exist in the target dictionary.
"""
for key, value in source.items():
if key not in target:
target[key] = value
def set_into(target: Dict[str, Any], key: str, value: Any) -> None:
"""
Sets a new key/value into the target dictionary.
Given that the key does not exist in the target dictionary.
"""
if key not in target:
target[key] = value
def is_domain_set(
config: Dict[str, Any],
*properties: str,
) -> bool:
"""
Checks if a domain is set in the config.
"""
for prop in properties:
# If the . prefix exists, check if the domain is a prefix of any key in the config
if prop[-1] in ('.', ':'):
if any(key.startswith(prop) for key in config):
return True
# Otherwise, check if the domain is a direct key in the config
else:
if prop in config:
return True
return False
def warn_manual_config(config: Dict[str, Any]) -> None:
"""
Warns the user if they are manually setting properties that Camoufox already sets internally.
"""
# Manual locale setting
if is_domain_set(
config, 'navigator.language', 'navigator.languages', 'headers.Accept-Language', 'locale:'
):
LeakWarning.warn('locale', False)
# Manual geolocation and timezone setting
if is_domain_set(config, 'geolocation:', 'timezone'):
LeakWarning.warn('geolocation', False)
# Manual User-Agent setting
if is_domain_set(config, 'headers.User-Agent'):
LeakWarning.warn('header-ua', False)
# Manual navigator setting
if is_domain_set(config, 'navigator.'):
LeakWarning.warn('navigator', False)
# Manual screen/window setting
if is_domain_set(config, 'screen.', 'window.', 'document.body.'):
LeakWarning.warn('viewport', False)
_WINDOW_DIM_KEYS = (
'window.outerWidth',
'window.outerHeight',
'window.innerWidth',
'window.innerHeight',
'document.body.clientWidth',
'document.body.clientHeight',
)
def spoofs_window_dimensions(from_options: Dict[str, Any]) -> bool:
"""
Whether the CAMOU_CONFIG in a set of launch options spoofs any window
dimension. The config is chunked across CAMOU_CONFIG_<n> env vars, so
reassemble it in index order before looking.
"""
env = from_options.get('env') or {}
chunks = [(int(k.rsplit('_', 1)[1]), v) for k, v in env.items() if k.startswith('CAMOU_CONFIG_')]
if not chunks:
return False
blob = ''.join(v for _, v in sorted(chunks))
return any(key in blob for key in _WINDOW_DIM_KEYS)
def attach_no_viewport_default(target: Any) -> Any:
"""
Default new_page()/new_context() to no_viewport=True.
Playwright applies a 1280x720 viewport by default, which makes Juggler ask
the content window to become 1280x720 (TargetRegistry.updateViewportSize).
When Camoufox is pinning the window to a spoofed size, that request can
never be satisfied, and awaitViewportDimensions has no timeout -- so the
second new_page() hangs forever (daijro/camoufox#666).
With no_viewport, Juggler measures the window instead of resizing it, so the
handshake resolves immediately and the page reports the spoofed dimensions
exactly. Explicit viewport=/no_viewport= from the caller always wins.
"""
for name in ('new_page', 'new_context'):
original = getattr(target, name, None)
if original is None:
continue
def wrap(original: Any) -> Any:
@wraps(original)
def wrapper(*args: Any, **kwargs: Any) -> Any:
if 'viewport' not in kwargs and 'no_viewport' not in kwargs:
kwargs['no_viewport'] = True
# Works for both sync and async: async returns the coroutine
# unawaited, and the caller awaits it as usual.
return original(*args, **kwargs)
return wrapper
setattr(target, name, wrap(original))
return target
async def async_attach_vd(
browser: Any, virtual_display: Optional[VirtualDisplay] = None
) -> Any: # type: ignore
"""
Attaches the virtual display to the async browser cleanup
"""
if not virtual_display: # Skip if no virtual display is provided
return browser
_close = browser.close
async def new_close(*args: Any, **kwargs: Any):
try:
await _close(*args, **kwargs)
except Exception:
raise
finally:
if virtual_display:
virtual_display.kill()
browser.close = new_close
browser._virtual_display = virtual_display
return browser
def sync_attach_vd(
browser: Any, virtual_display: Optional[VirtualDisplay] = None
) -> Any: # type: ignore
"""
Attaches the virtual display to the sync browser cleanup
"""
if not virtual_display: # Skip if no virtual display is provided
return browser
_close = browser.close
def new_close(*args: Any, **kwargs: Any):
try:
_close(*args, **kwargs)
except Exception:
raise
finally:
if virtual_display:
virtual_display.kill()
browser.close = new_close
browser._virtual_display = virtual_display
return browser
def launch_options(
*,
config: Optional[Dict[str, Any]] = None,
os: Optional[ListOrString] = None,
block_images: Optional[bool] = None,
block_webrtc: Optional[bool] = None,
block_webgl: Optional[bool] = None,
disable_coop: Optional[bool] = None,
webgl_config: Optional[Tuple[str, str]] = None,
geoip: Optional[Union[str, bool]] = None,
geoip_db: Optional[str] = None,
humanize: Optional[Union[bool, float]] = None,
locale: Optional[Union[str, List[str]]] = None,
addons: Optional[List[str]] = None,
fonts: Optional[List[str]] = None,
custom_fonts_only: Optional[bool] = None,
exclude_addons: Optional[List[DefaultAddons]] = None,
screen: Optional[Screen] = None,
window: Optional[Tuple[int, int]] = None,
fingerprint: Optional[Fingerprint] = None,
fingerprint_preset: Optional[Union[bool, Dict[str, Any]]] = None,
ff_version: Optional[int] = None,
headless: Optional[bool] = None,
main_world_eval: Optional[bool] = None,
allow_addon_new_tab: Optional[bool] = None,
executable_path: Optional[Union[str, Path]] = None,
browser: Optional[str] = None,
firefox_user_prefs: Optional[Dict[str, Any]] = None,
proxy: Optional[Dict[str, str]] = None,
enable_cache: Optional[bool] = None,
args: Optional[List[str]] = None,
env: Optional[Dict[str, Union[str, float, bool]]] = None,
i_know_what_im_doing: Optional[bool] = None,
debug: Optional[bool] = None,
virtual_display: Optional[str] = None,
**launch_options: Dict[str, Any],
) -> Dict[str, Any]:
"""
Launches a new browser instance for Camoufox.
Accepts all Playwright Firefox launch options, along with the following:
Parameters:
config (Optional[Dict[str, Any]]):
Camoufox properties to use. (read https://github.com/daijro/camoufox/blob/main/README.md)
os (Optional[ListOrString]):
Operating system to use for the fingerprint generation.
Can be "windows", "macos", "linux", or a list to randomly choose from.
Default: ["windows", "macos", "linux"]
block_images (Optional[bool]):
Whether to block all images.
block_webrtc (Optional[bool]):
Whether to block WebRTC entirely.
block_webgl (Optional[bool]):
Whether to block WebGL. To prevent leaks, only use this for special cases.
disable_coop (Optional[bool]):
Disables the Cross-Origin-Opener-Policy, allowing elements in cross-origin iframes,
such as the Turnstile checkbox, to be clicked.
geoip (Optional[Union[str, bool]]):
Calculate longitude, latitude, timezone, country, & locale based on the IP address.
Pass the target IP address to use, or `True` to find the IP address automatically.
geoip_db (Optional[str]):
Name of the GeoIP database to use (e.g., "MaxMind").
If not specified, uses the configured default.
humanize (Optional[Union[bool, float]]):
Humanize the cursor movement.
Takes either `True`, or the MAX duration in seconds of the cursor movement.
The cursor typically takes up to 1.5 seconds to move across the window.
locale (Optional[Union[str, List[str]]]):
Locale(s) to use in Camoufox. The first listed locale will be used for the Intl API.
addons (Optional[List[str]]):
List of Firefox addons to use.
fonts (Optional[List[str]]):
Fonts to load into Camoufox (in addition to the default fonts for the target `os`).
Takes a list of font family names that are installed on the system.
custom_fonts_only (Optional[bool]):
If enabled, OS-specific system fonts will be not be passed to Camoufox.
exclude_addons (Optional[List[DefaultAddons]]):
Default addons to exclude. Passed as a list of camoufox.DefaultAddons enums.
screen (Optional[Screen]):
Constrains the screen dimensions of the generated fingerprint.
Takes a browserforge.fingerprints.Screen instance.
window (Optional[Tuple[int, int]]):
Set a fixed window size instead of generating a random one
fingerprint (Optional[Fingerprint]):
Use a custom BrowserForge fingerprint. Note: Not all values will be implemented.
If not provided, a random fingerprint will be generated based on the provided
`os` & `screen` constraints.
fingerprint_preset (Optional[Union[bool, Dict[str, Any]]]):
Opt into using real fingerprint presets instead of BrowserForge.
Pass `True` to use a random bundled preset, or pass a preset dict directly.
By default (None), BrowserForge is used for infinite unique fingerprints.
ff_version (Optional[int]):
Firefox version to use. Defaults to the current Camoufox version.
To prevent leaks, only use this for special cases.
headless (Optional[bool]):
Whether to run the browser in headless mode. Defaults to False.
Note: If you are running linux, passing headless='virtual' to Camoufox & AsyncCamoufox
will use Xvfb.
main_world_eval (Optional[bool]):
Whether to enable running scripts in the main world.
To use this, prepend "mw:" to the script: page.evaluate("mw:" + script).
allow_addon_new_tab (Optional[bool]):
Whether to allow addon open new tabs. Defaults to False.
executable_path (Optional[Union[str, Path]]):
Custom Camoufox browser executable path.
browser (Optional[str]):
Select a specific installed browser version. Can be:
- Repo/build like "official/beta.20"
- Build alone like "beta.20"
- Full version like "134.0.2-beta.20"
If not specified, uses the active version.
firefox_user_prefs (Optional[Dict[str, Any]]):
Firefox user preferences to set.
proxy (Optional[Dict[str, str]]):
Proxy to use for the browser.
Note: If geoip is True, a request will be sent through this proxy to find the target IP.
enable_cache (Optional[bool]):
Cache previous pages, requests, etc (uses more memory).
args (Optional[List[str]]):
Arguments to pass to the browser.
env (Optional[Dict[str, Union[str, float, bool]]]):
Environment variables to set.
debug (Optional[bool]):
Prints the config being sent to Camoufox.
virtual_display (Optional[str]):
Virtual display number. Ex: ':99'. This is handled by Camoufox & AsyncCamoufox.
webgl_config (Optional[Tuple[str, str]]):
Use a specific WebGL vendor/renderer pair. Passed as a tuple of (vendor, renderer).
**launch_options (Dict[str, Any]):
Additional Firefox launch options.
"""
ensure_browser_profile_dir(env)
# Build the config
if config is None:
config = {}
# Set default values for optional arguments
if headless is None:
headless = False
if addons is None:
addons = []
if args is None:
args = []
if firefox_user_prefs is None:
firefox_user_prefs = {}
if custom_fonts_only is None:
custom_fonts_only = False
if i_know_what_im_doing is None:
i_know_what_im_doing = False
# Keep per-launch overrides isolated from the process environment and from
# mappings supplied by callers. In particular, DISPLAY must not outlive the
# virtual display that owns it.
env = dict(environ) if env is None else dict(env)
if isinstance(executable_path, str):
# Convert executable path to a Path object
executable_path = Path(abspath(executable_path))
# Handle virtual display
if virtual_display:
env['DISPLAY'] = virtual_display
# Virtual display uses Xvfb (X11). If the host session forces Wayland via env vars,
# GTK/Firefox may try Wayland and ignore DISPLAY, breaking Xvfb usage.
env['GDK_BACKEND'] = 'x11'
env.pop('WAYLAND_DISPLAY', None)
env["MOZ_ENABLE_WAYLAND"] = "0"
# Warn the user for manual config settings
if not i_know_what_im_doing:
warn_manual_config(config)
# Snapshot which domains the USER set before fingerprint generation fills in
# the rest. The post-generation BrowserForge-correction fixes below must
# only touch generated values, never override what the user passed.
_user_set_navigator = is_domain_set(config, 'navigator.')
_user_set_screen_window = is_domain_set(config, 'screen.', 'window.')
_user_set_media_devices = is_domain_set(config, 'mediaDevices:')
# Assert the target OS is valid
if os:
check_valid_os(os)
# webgl_config requires OS to be set
elif webgl_config:
raise ValueError('OS must be set when using webgl_config')
# Add the default addons
add_default_addons(addons, exclude_addons)
# Confirm all addon paths are valid
if addons:
confirm_paths(addons)
config['addons'] = addons
# Get the Firefox version
if ff_version:
ff_version_str = str(ff_version)
LeakWarning.warn('ff_version', i_know_what_im_doing)
else:
ff_version_str = installed_verstr().split('.', 1)[0]
# Generate a fingerprint
_used_preset = False
if fingerprint is not None:
# User passed a custom BrowserForge fingerprint
if not i_know_what_im_doing:
check_custom_fingerprint(fingerprint)
elif fingerprint_preset is not None:
# User opted into real fingerprint presets
if isinstance(fingerprint_preset, dict):
preset = fingerprint_preset
else:
preset = get_random_preset(os=os, ff_version=ff_version_str)
if preset:
merge_into(config, from_preset(preset, ff_version_str))
_used_preset = True
# Bound the geometry to the real display. BrowserForge only honours this when
# its pool has a match, so it is re-applied after generation as well.
# `headless` and "is there a display to probe" are separate questions: passing
# `headless or has_display(env)` made a headful run on a real display look like a
# headless one to get_screen_cons(), which then skipped the bound entirely.
screen_cons = screen or (get_screen_cons(headless) if has_display(env) else None)
if not _used_preset and fingerprint is None:
# Default: BrowserForge synthetic generation (infinite unique fingerprints)
fingerprint = generate_fingerprint(
screen=screen_cons,
window=window,
os=os,
)
if not _used_preset and fingerprint is not None:
# Inject the BrowserForge fingerprint into the config
merge_into(
config,
from_browserforge(fingerprint, ff_version_str),
)
target_os = get_target_os(config)
# Correct BrowserForge fingerprint inconsistencies that leak as headless /
# impossible-geometry tells, unless the user is driving these themselves.
if not _user_set_navigator:
fix_navigator_arch(config, target_os)
if not _user_set_screen_window:
# Lift netbook-era geometry to something current hardware reports,
# before the display clamp below so a genuinely small real monitor
# still wins (#729). Synthetic draws only: a preset is a real device,
# internally consistent by construction, and two of the bundled v150
# presets genuinely report sub-netbook screens (736x414, 960x540).
# Rewriting those to 1366x768 would break the very coherence #729 is
# about, and _user_set_screen_window is read before the preset merges
# in, so it does not cover this.
if not _used_preset:
raise_screen_to_modern_floor(config)
# Headful on a real monitor only: this bound exists so the window fits
# the screen it is drawn on. headless has no window to overflow, and
# headless='virtual' reaches here as headless=False (see async_api) with
# a 1x1 Xvfb (virtdisplay.py) that is not a real screen.
if headless is False and not virtual_display and screen_cons:
clamp_screen_to_display(config, screen_cons.max_width, screen_cons.max_height)
fix_screen_no_taskbar(config, target_os)
clamp_window_dimensions(config)
clamp_window_position(config)
# Deliberately NOT setting window.history.length. It used to be pinned to a
# random 1-5 because browser.sessionhistory.max_entries=0 left the real
# session history empty, so the honest value was 0 -- an impossible number,
# since the HTML spec guarantees a browsing context always keeps its current
# entry. settings/camoufox.cfg now runs Firefox's stock max_entries, so the
# real value starts at 1 and grows with each navigation.
#
# Pinning it on top of that is strictly worse than leaving it alone: the
# value would no longer move across navigations, and a fresh tab would claim
# a depth of, say, 4 while history.back() -- which reads the real session
# history -- does nothing. Any page can check that pair. The property stays
# in properties.json for callers who want to override it by hand.
# Update fonts list
if fonts:
config['fonts'] = fonts
if custom_fonts_only:
firefox_user_prefs['gfx.bundled-fonts.activate'] = 0
if fonts:
LeakWarning.warn('custom_fonts_only')
else:
raise ValueError('No custom fonts were passed, but `custom_fonts_only` is enabled.')
elif 'fonts' not in config or not config.get('fonts'):
# Generate a unique random font subset from the OS font list
os_name = {'win': 'windows', 'mac': 'macos', 'lin': 'linux'}.get(target_os, 'macos')
try:
config['fonts'] = _generate_random_font_subset(os_name)
except Exception:
update_fonts(config, target_os)
# Spoof the speech-synthesis voice list.
#
# This has to fail CLOSED. Firefox registers the host's speech-dispatcher /
# SAPI / NSSpeech voices unless something stops it, and nsSynthVoiceRegistry
# only stops it when Camoufox owns the list. Leaving `voices` unset -- which
# the old `except Exception: pass` did on any generation failure -- exposed
# every native voice on the box (14805 espeak-ng entries on a stock Linux
# install) under a fingerprint claiming macOS or Windows: it both leaks the
# real host OS and contradicts the rest of the profile (#731).
if 'voices' not in config:
os_name_v = {'win': 'windows', 'mac': 'macos', 'lin': 'linux'}.get(target_os, 'macos')
try:
config['voices'] = _generate_random_voice_subset(
os_name_v, config.get('navigator.language')
)
except Exception:
# An empty list still blocks the host's voices (see below), so a
# generation failure degrades to "no voices" rather than "all of
# the host's".
config['voices'] = []
# Pin the block explicitly instead of relying on a non-empty list to imply
# it, so an empty list -- or one whose entries the browser rejects as
# malformed -- cannot fall through to the host's native voices. set_into
# leaves an explicit caller value alone.
set_into(config, 'voices:blockIfNotDefined', True)
# Default mediaDevices to one mic + one camera so headless contexts don't
# expose an empty enumerateDevices() list (a headless tell).
if not _user_set_media_devices:
set_media_devices_defaults(config)
# Set random seeds for fingerprint noise (per launch)
set_into(config, 'fonts:spacing_seed', randint(1, 4_294_967_295)) # nosec
set_into(config, 'audio:seed', randint(1, 4_294_967_295)) # nosec
set_into(config, 'canvas:seed', randint(1, 4_294_967_295)) # nosec
# Set geolocation
if geoip:
geoip_allowed() # Assert that geoip is allowed
if geoip is True:
# Find the user's IP address
if proxy:
geoip = public_ip(Proxy(**proxy).as_string())
else:
geoip = public_ip()
# Spoof WebRTC if not blocked
if not block_webrtc:
if valid_ipv4(geoip):
set_into(config, 'webrtc:ipv4', geoip)
firefox_user_prefs['network.dns.disableIPv6'] = True
elif valid_ipv6(geoip):
set_into(config, 'webrtc:ipv6', geoip)
geolocation = get_geolocation(geoip, geoip_db=geoip_db)
geo_config = geolocation.as_config()
for key, value in geo_config.items():
if key in ('timezone', 'locale:language', 'locale:region', 'locale:script'):
config.setdefault(key, value)
else:
config[key] = value
# Raise a warning when a proxy is being used without spoofing geolocation.
# This is a very bad idea; the warning cannot be ignored with i_know_what_im_doing.
elif (
proxy
and 'localhost' not in proxy.get('server', '')
and not is_domain_set(config, 'geolocation')
):
LeakWarning.warn('proxy_without_geoip')
# Set locale
if locale:
handle_locales(locale, config)
# Pass the humanize option
if humanize:
set_into(config, 'humanize', True)
# bool is a subclass of int, but MaskConfig expects maxTime to be a
# JSON number with a floating-point representation.
if isinstance(humanize, (int, float)) and not isinstance(humanize, bool):
set_into(config, 'humanize:maxTime', float(humanize))
# Enable the main world context creation
if main_world_eval:
set_into(config, 'allowMainWorld', True)
# Allow addon open new tabs
if allow_addon_new_tab:
set_into(config, 'allowAddonNewtab', True)
# Set Firefox user preferences
if block_images:
LeakWarning.warn('block_images', i_know_what_im_doing)
firefox_user_prefs['permissions.default.image'] = 2
if block_webrtc:
firefox_user_prefs['media.peerconnection.enabled'] = False
if disable_coop:
LeakWarning.warn('disable_coop', i_know_what_im_doing)
firefox_user_prefs['browser.tabs.remote.useCrossOriginOpenerPolicy'] = False
# Allow allow_webgl parameter for backwards compatibility
if block_webgl or launch_options.pop('allow_webgl', True) is False:
firefox_user_prefs['webgl.disabled'] = True
LeakWarning.warn('block_webgl', i_know_what_im_doing)
else:
# If the user has provided a specific WebGL vendor/renderer pair, use it
if webgl_config:
webgl_fp = sample_webgl(target_os, *webgl_config)
elif config.get('webGl:vendor') and config.get('webGl:renderer'):
# Preset already set vendor/renderer — sample matching WebGL params
webgl_fp = sample_webgl(target_os, config['webGl:vendor'], config['webGl:renderer'])
else:
# Synthetic path: keep the GPU coherent with the screen BrowserForge
# already picked. Sampling the two independently yields pairs no
# real machine ships -- a discrete desktop GPU behind a 1024x600
# panel -- which consistency checks read as masking (#729).
webgl_fp = sample_webgl_for_screen(
target_os, config.get('screen.width'), config.get('screen.height')
)
enable_webgl2 = webgl_fp.pop('webGl2Enabled')
# Merge the WebGL fingerprint into the config
merge_into(config, webgl_fp)
# Set the WebGL preferences
merge_into(
firefox_user_prefs,
{
'webgl.enable-webgl2': enable_webgl2,
'webgl.force-enabled': True,
},
)
# Cache previous pages, requests, etc (uses more memory)
if enable_cache:
merge_into(firefox_user_prefs, CACHE_PREFS)
# Print the config if debug is enabled
if debug:
print('[DEBUG] Config:')
pprint(config)
# Validate the config
warn_if_executable_predates_playwright(executable_path)
validate_config(config, path=executable_path)
# Prepare environment variables to pass to Camoufox
env_vars = {
**get_env_vars(config, target_os, path=executable_path),
**env,
}
# Prepare the executable path
if executable_path:
executable_path = str(executable_path)
elif browser:
# Select a specific installed browser version
from .multiversion import find_installed_version
browser_path = find_installed_version(browser)
if not browser_path:
raise ValueError(
f"Browser version '{browser}' not found. Run `camoufox list` to see installed versions."
)
executable_path = launch_path(browser_path)
else:
executable_path = launch_path()
result = {
"executable_path": executable_path,
"args": args,
"env": env_vars,
"firefox_user_prefs": firefox_user_prefs,
"headless": headless,
**(launch_options if launch_options is not None else {}),
}
# Only include proxy if it's not None (Playwright 1.55+ validates this)
# https://github.com/coryking/camoufox/commit/1336e8e509e8c12a896a09d9ee51f131f739f106
# Thanks @coryking
if proxy is not None:
result["proxy"] = proxy
return result