fix(pythonlib): identity draws that match real machines and stay stable

Launcher-side fixes found by comparing camoufox against stock Firefox 152.0.4
on Linux, Windows 11 and macOS hosts:

- DNT / GPC: BrowserForge draws doNotTrack "1" on most Firefox samples, but a
  stock Firefox 152 reports "unspecified" and globalPrivacyControl false; the
  stock defaults are used unless the caller sets them, and both are applied as
  prefs so the API, the worker and the DNT / Sec-GPC headers agree (#760).
- Timezone and geolocation: the timezone is passed to the browser, and a
  configured position sets permissions.default.geo so permissions.query agrees
  with the auto-grant (#769, #773).
- hardwareConcurrency: the reported count is the fingerprint's and the browser
  is pinned to that many cores (cpu_affinity.py, Linux/Windows), so worker
  timing agrees with it; otherwise the host count snapped into the core counts
  real machines ship with (never 2, Firefox's resistFingerprinting value).
- Fonts: the OS base is always present in full, OS-version variants are drawn
  all-or-nothing, co-shipped groups stay together, Cascadia is never claimed
  off Windows, a native macOS/Windows identity claims only the real OS base,
  and gfx.font_rendering.fallback.async is off on Linux so per-character
  fallback does not depend on cmap-load timing.
- Speech voices: a per-OS installed-voice model (voice-manifests.json) with
  the voiceURI formats each backend really produces (voice-uris.json); no
  default voice where stock has none.
- WebGL: extensions a release Firefox never exposes are filtered, but
  OVR_multiview2 stays for Windows D3D11 renderers, which expose it.
- Media devices: a seeded draw of common per-OS devices with OS-style labels.
- Windows scrollbars follow the drawn Windows version (overlay on 11).
- Glyph-advance perturbation (fonts:spacing_seed) defaults to off: it moved
  every measureText width off the value the same font gives on a real machine.
- Launcher prefs are also exported as CAMOU_PREFS_1..N so camoufox.cfg applies
  them at startup, and the browser UI locale follows the spoofed locale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jake Writer
2026-09-16 22:09:40 -06:00
co-authored by Claude Opus 5
parent 8c7eec556f
commit 3f0f850bf3
14 changed files with 2557 additions and 145 deletions
+24 -11
View File
@@ -114,18 +114,31 @@ async def AsyncNewBrowser(
# 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)
# 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
# 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)
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}
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)
finally:
if pid:
cpu_affinity.restore(pid, previous)
def _proxy_url_with_creds(proxy: Dict[str, str]) -> str:
+140
View File
@@ -0,0 +1,140 @@
"""Pin the browser to as many CPU cores as the identity reports.
navigator.hardwareConcurrency is spoofed by the browser, but the number of
cores a page can *measure* (timing N parallel workers) is the number the OS
lets the browser run on. Reporting the fingerprint's value and pinning the
browser's CPU affinity to that many cores makes the two agree, so the drawn
value survives instead of being replaced by the host count.
The pin is applied to the Playwright driver process right before the browser
is launched -- child processes inherit the affinity mask on Linux and Windows,
so the browser and every content/GPU process it spawns run on the pinned set
-- and lifted from the driver again afterwards. macOS has no process affinity
API, so nothing can be pinned there and the launcher falls back to reporting
the host's (snapped) count.
"""
import os
import platform
from typing import Iterable, List, Optional, Sequence
def supported() -> bool:
"""Whether this host can constrain a process to a subset of its cores."""
system = platform.system()
if system == 'Linux':
return hasattr(os, 'sched_setaffinity')
return system == 'Windows'
def host_cores() -> Optional[List[int]]:
"""The cores this process may run on, in order."""
try:
if hasattr(os, 'sched_getaffinity'):
return sorted(os.sched_getaffinity(0)) # type: ignore[attr-defined]
except OSError:
pass
if platform.system() == 'Windows':
mask = _win_get_mask(os.getpid())
if mask:
return _mask_to_cores(mask)
n = os.cpu_count()
return list(range(n)) if n else None
def pin(pid: int, count: int) -> Optional[Sequence[int]]:
"""Restrict `pid` to its first `count` cores. Returns the previous set so
it can be handed back to `restore()`, or None if nothing was changed."""
if count < 1 or not supported():
return None
system = platform.system()
if system == 'Linux':
try:
before = sorted(os.sched_getaffinity(pid)) # type: ignore[attr-defined]
if count >= len(before):
return None
os.sched_setaffinity(pid, set(before[:count])) # type: ignore[attr-defined]
return before
except OSError:
return None
if system == 'Windows':
before_mask = _win_get_mask(pid)
if not before_mask:
return None
before = _mask_to_cores(before_mask)
if count >= len(before):
return None
return before if _win_set_mask(pid, _cores_to_mask(before[:count])) else None
return None
def restore(pid: int, previous: Optional[Sequence[int]]) -> None:
"""Give `pid` back the cores it had before `pin()`."""
if not previous:
return
system = platform.system()
try:
if system == 'Linux':
os.sched_setaffinity(pid, set(previous)) # type: ignore[attr-defined]
elif system == 'Windows':
_win_set_mask(pid, _cores_to_mask(previous))
except OSError:
pass
# -- Windows ---------------------------------------------------------------
_PROCESS_QUERY_INFORMATION = 0x0400
_PROCESS_SET_INFORMATION = 0x0200
def _mask_to_cores(mask: int) -> List[int]:
return [i for i in range(mask.bit_length()) if mask >> i & 1]
def _cores_to_mask(cores: Iterable[int]) -> int:
mask = 0
for c in cores:
mask |= 1 << c
return mask
def _win_handle(pid: int):
import ctypes
k32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
return k32, k32.OpenProcess(_PROCESS_QUERY_INFORMATION | _PROCESS_SET_INFORMATION, False, pid)
def _win_get_mask(pid: int) -> int:
try:
import ctypes
k32, h = _win_handle(pid)
if not h:
return 0
try:
proc_mask = ctypes.c_size_t()
sys_mask = ctypes.c_size_t()
if not k32.GetProcessAffinityMask(h, ctypes.byref(proc_mask), ctypes.byref(sys_mask)):
return 0
return int(proc_mask.value)
finally:
k32.CloseHandle(h)
except Exception:
return 0
def _win_set_mask(pid: int, mask: int) -> bool:
try:
import ctypes
k32, h = _win_handle(pid)
if not h:
return False
try:
return bool(k32.SetProcessAffinityMask(h, ctypes.c_size_t(mask)))
finally:
k32.CloseHandle(h)
except Exception:
return False
File diff suppressed because it is too large Load Diff
+24 -11
View File
@@ -113,18 +113,31 @@ def NewBrowser(
# 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 = playwright.firefox.launch_persistent_context(**from_options)
return sync_attach_vd(context, virtual_display)
# 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
# Browser
browser = playwright.firefox.launch(**from_options)
if no_viewport_default:
attach_no_viewport_default(browser)
return sync_attach_vd(browser, virtual_display)
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}
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)
return sync_attach_vd(browser, virtual_display)
finally:
if pid:
cpu_affinity.restore(pid, previous)
def _proxy_url_with_creds(proxy: Dict[str, str]) -> str:
+258 -16
View File
@@ -1,4 +1,5 @@
import os
import platform
import sys
from functools import wraps
from os import environ
@@ -21,7 +22,7 @@ from .exceptions import (
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 .fingerprints import from_browserforge, from_preset, generate_fingerprint, get_random_preset, _generate_random_font_subset, _generate_random_voice_subset, fix_navigator_arch, fix_hardware_concurrency, identity_seed, 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, WINDOWS_11_MARKER_FONTS
from .geolocation import geoip_allowed, get_geolocation
from .ip import Proxy, public_ip, valid_ipv4, valid_ipv6
from .locales import handle_locales
@@ -53,7 +54,14 @@ CACHE_PREFS = {
}
def _generate_fontconfig(fontconfig_path: str, path: Optional[Path] = None) -> str:
def _host_os_key() -> Optional[str]:
"""The host OS in fonts.json / target_os terms ('mac', 'win', 'lin')."""
return {'Darwin': 'mac', 'Windows': 'win', 'Linux': 'lin'}.get(platform.system())
def _generate_fontconfig(
fontconfig_path: str, path: Optional[Path] = None, os_dir: Optional[str] = None
) -> str:
"""
Generates a runtime fontconfig that resolves bundled font paths absolutely.
The bundled fonts.conf uses prefix="cwd" relative paths which break when
@@ -67,6 +75,15 @@ def _generate_fontconfig(fontconfig_path: str, path: Optional[Path] = None) -> s
# 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")
# The Linux package ships every OS's font set under fonts/<os>/ so one
# artifact can claim any OS. fontconfig scans <dir> recursively, so
# pointing it at the parent makes the other two OSes' files reachable by
# the renderer -- hidden by the allowlist for direct lookups, but still
# candidates for glyph fallback (an emoji or CJK glyph from Segoe UI
# Emoji / PingFang on a machine claiming Linux). Scope the directory to
# the claimed OS whenever the package has that layout.
if os_dir and os.path.isdir(os.path.join(fonts_dir, os_dir)):
fonts_dir = os.path.join(fonts_dir, os_dir)
fonts_conf_src = os.path.join(fontconfig_path, "fonts.conf")
with open(fonts_conf_src, 'r') as f:
@@ -141,6 +158,29 @@ def _resolved_playwright_version_str() -> str:
return 'the installed version'
def get_pref_env_vars(prefs: Dict[str, Any]) -> Dict[str, str]:
"""
Pass the launcher's Firefox prefs to settings/camoufox.cfg, which applies them
at STARTUP (CAMOU_PREFS_1..N, chunked like CAMOU_CONFIG).
Playwright's non-persistent launch writes no user.js: firefox_user_prefs only
reach the browser at runtime, through juggler's Browser.enable, after startup.
Anything Gecko reads during startup therefore raced or never applied -- e.g.
intl.locale.requested lost the race against the parent's pre-created
dom.properties string bundles on Windows (fr-FR validation messages English
in 3 of 4 launches), and mirror-once prefs such as
gfx.bundled-fonts.activate were ignored outright.
"""
if not prefs:
return {}
data = orjson.dumps(prefs).decode('utf-8')
chunk_size = 2047 if OS_NAME == 'win' else 32767
return {
f"CAMOU_PREFS_{(i // chunk_size) + 1}": data[i : i + chunk_size]
for i in range(0, len(data), chunk_size)
}
def get_env_vars(
config_map: Dict[str, str],
user_agent_os: str,
@@ -203,7 +243,7 @@ def get_env_vars(
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)
env_vars['FONTCONFIG_FILE'] = _generate_fontconfig(fontconfig_path, path=path, os_dir=os_dir)
return env_vars
@@ -214,6 +254,12 @@ def _load_properties(path: Optional[Path] = None) -> Dict[str, str]:
"""
if path:
prop_file = str(path.parent / "properties.json")
if not os.path.exists(prop_file):
# macOS app bundle: the binary is Contents/MacOS/camoufox, the
# packaged settings live in Contents/Resources/.
bundled = path.parent.parent / "Resources" / "properties.json"
if bundled.exists():
prop_file = str(bundled)
else:
prop_file = get_path("properties.json")
with open(prop_file, "rb") as f:
@@ -472,6 +518,42 @@ _WINDOW_DIM_KEYS = (
)
def _camou_config_blob(from_options: Dict[str, Any]) -> str:
env = from_options.get('env') or {}
chunks = [(int(k.rsplit('_', 1)[1]), v) for k, v in env.items() if k.startswith('CAMOU_CONFIG_')]
return ''.join(v for _, v in sorted(chunks))
def pinned_core_count(from_options: Dict[str, Any]) -> Optional[int]:
"""The core count the browser must be pinned to for these launch options,
or None: the identity's navigator.hardwareConcurrency when this host can
honour it (see cpu_affinity), so a page measuring parallelism sees the
reported number."""
from .cpu_affinity import host_cores, supported
blob = _camou_config_blob(from_options)
if not blob or not supported():
return None
try:
value = orjson.loads(blob).get('navigator.hardwareConcurrency')
except (orjson.JSONDecodeError, AttributeError):
return None
cores = host_cores()
if isinstance(value, int) and cores and 1 <= value < len(cores):
return value
return None
def driver_pid(playwright: Any) -> Optional[int]:
"""PID of the Playwright driver that will spawn the browser (its children
inherit the CPU affinity we set on it)."""
try:
impl = getattr(playwright, '_impl_obj', playwright)
return int(impl._connection._transport._proc.pid)
except Exception:
return None
def spoofs_window_dimensions(from_options: Dict[str, Any]) -> bool:
"""
Whether the CAMOU_CONFIG in a set of launch options spoofs any window
@@ -784,6 +866,11 @@ def launch_options(
_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:')
_user_set_fonts = bool(fonts) or is_domain_set(config, 'fonts')
_user_set_voices = is_domain_set(config, 'voices')
_user_set_dnt = 'navigator.doNotTrack' in config
_user_set_gpc = 'navigator.globalPrivacyControl' in config
_user_set_accept_encoding = 'headers.Accept-Encoding' in config
# Assert the target OS is valid
if os:
@@ -852,6 +939,7 @@ def launch_options(
# impossible-geometry tells, unless the user is driving these themselves.
if not _user_set_navigator:
fix_navigator_arch(config, target_os)
fix_hardware_concurrency(config)
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
@@ -896,11 +984,21 @@ def launch_options(
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
elif not _user_set_fonts or not config.get('fonts'):
# Draw the font subset HERE, after every identity fix-up above, so the
# seed sees the final UA/screen/cores/GPU: the same presented identity
# always gets the same font list (#442/#765). A draw the fingerprint
# generator made earlier from a partial config is replaced.
os_name = {'win': 'windows', 'mac': 'macos', 'lin': 'linux'}.get(target_os, 'macos')
try:
config['fonts'] = _generate_random_font_subset(os_name)
config['fonts'] = _generate_random_font_subset(
os_name,
seed=identity_seed(config),
# host's own OS on macOS/Windows: the real system fonts are used
# (font-hijacker.patch keeps the bundle inactive), so only the
# OS base is claimed
native=(target_os in ('mac', 'win') and _host_os_key() == target_os),
)
except Exception:
update_fonts(config, target_os)
@@ -913,11 +1011,11 @@ def launch_options(
# 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:
if not _user_set_voices or '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')
os_name_v, config.get('navigator.language'), seed=identity_seed(config)
)
except Exception:
# An empty list still blocks the host's voices (see below), so a
@@ -931,15 +1029,124 @@ def launch_options(
# 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).
# Draw the identity's media devices (counts + OS-style labels/groups from
# media-devices.json, seeded by the identity) unless the caller set any
# mediaDevices: key. An empty enumerateDevices() list is a headless tell;
# a wrong label after a grant is a spoof tell.
if not _user_set_media_devices:
set_media_devices_defaults(config)
# Scrollbars: a stock Firefox on a GNOME/KDE desktop and on macOS draws
# overlay scrollbars (no layout gutter, scrollbar-width "auto"). On Windows it
# follows the OS: Windows 11's default ("Always show scrollbars" off) is
# overlay -- stock 152.0.4 on a Win11 laptop measures 0 px -- while Windows 10
# draws classic 17 px ones. Headless Firefox reports the classic kind, and
# upstream Playwright hid them outright, which a page can read back. Pin the
# look-and-feel to the claimed OS so headless == headed == stock, and for
# Windows to the version the identity's font draw presents (the Win11-only
# fonts in _BASE_VARIANT_FONTS_WINDOWS): Win11 fonts with classic scrollbars
# is a pair no real machine produces.
if target_os == 'win':
presented_fonts = config.get('fonts') or []
windows_11 = not presented_fonts or any(
font in presented_fonts for font in WINDOWS_11_MARKER_FONTS
)
firefox_user_prefs.setdefault('ui.useOverlayScrollbars', 1 if windows_11 else 0)
else:
firefox_user_prefs.setdefault('ui.useOverlayScrollbars', 1)
# Per-character font fallback, LINUX ONLY. Gecko's GlobalFontFallback walks
# the shared font list for a family whose charmap covers the character; in a
# content process with async fallback on it hits the
# `!family.IsFullyInitialized()` branch, schedules a cmap load and SKIPS the
# family, so the first measurement of a character only one bundled family
# provides returns the primary family's .notdef. Linux takes that path for
# every fallback (gfxPlatformGtk::UseCmapsDuringSystemFallback is true), so
# the font-hijacker fix that restored this on macOS cannot reach it here.
# Measured 2026-09-15, 32px canvas `serif`, U+0870: .notdef 19.0 with async
# on, a real glyph (9.25) with it off; stock Firefox resolves it.
# macOS must NOT get this: it uses the platform (CoreText) fallback, where
# forcing the synchronous scan changed the face picked for U+1E9E in Futura
# (21.733 stock -> 27.267) -- measured on a stock Mac mini, 1/14 families
# regressed. Windows is untested until a Windows build exists.
if target_os == 'lin':
firefox_user_prefs.setdefault('gfx.font_rendering.fallback.async', False)
# Bundled fonts: on macOS and Windows the package's font bundle is
# registered on top of the system fonts, and a bundled face of a family
# the system also has (Papyrus, Helvetica, ...) wins the lookup with
# metrics that differ from the real one (measured 2026-09-14 on a stock
# Mac mini: bundled Papyrus 224.3 px vs the system's 247.3 px). When the
# identity is the host's own OS the real system fonts ARE the right ones.
# `gfx.bundled-fonts.activate` cannot do it from here (a `once` pref the
# font list reads before profile prefs apply), so font-hijacker.patch
# skips the activation itself whenever navigator.platform is the host's;
# the font draw above claims only the OS base in that case (`native`).
# navigator.doNotTrack and navigator.globalPrivacyControl are pref-backed
# in Firefox: the main-thread getter, the WorkerNavigator getter and the
# DNT / Sec-GPC request headers all derive from the same pref. Spoofing
# the value anywhere else leaves the wire (or the worker) contradicting
# the API (daijro/camoufox#760), so the config keys are applied as prefs.
#
# The BrowserForge data carries doNotTrack "1" on most Firefox samples, but
# a stock Firefox 152 reports "unspecified" (the DNT setting was removed in
# 135) and globalPrivacyControl false outside private windows; measured
# 2026-09-14: every camoufox run said "1"/true, every stock run
# "unspecified"/false. So the generated values are dropped and the stock
# defaults used unless the caller set them explicitly.
# screen.colorDepth is left exactly as the identity drew it.
#
# It was briefly pinned to 24 here on the theory that "Firefox reports 24 on
# every desktop OS" (from a single headless Mac reading, 2026-09-14). That
# is WRONG and the pin was a fingerprinting regression, not a fix:
# * the recorded real-device corpus says macOS is 30 in 90-96% of presets
# (fingerprint-presets.json 27/30, -v150 64/67) and Windows/Linux are 24
# in 100% (75/75, 180/180 / 18/18, 65/65);
# * stock Firefox 152.0.4 on a real 10-bit Mac reports 30 -- measured on
# a Mac mini headed, 8/8 runs (the earlier "stock Mac mini 24" was
# HEADLESS, where the virtual screen genuinely is 8-bit);
# * the draw itself is already clean per OS (120/120 macOS -> 30,
# 120/120 Windows -> 24, 120/120 Linux -> 24), so the pin protected
# against nothing and only pushed macOS identities into the 4-10%
# minority.
# It also created a second leak: 24 was applied at the WebIDL level only, so
# it contradicted the CSS `color` media feature on a 10-bit panel
# (24 + `(color: 10)`, a pair Gecko cannot produce). The media feature now
# follows the spoofed depth (screen-spoofing.patch,
# Gecko_MediaFeatures_GetColorDepth), which is what makes ANY spoofed value
# -- including a cross-OS one -- internally coherent.
if not _user_set_dnt:
config.pop('navigator.doNotTrack', None)
if not _user_set_gpc:
config.pop('navigator.globalPrivacyControl', None)
dnt = config.get('navigator.doNotTrack')
firefox_user_prefs['privacy.donottrackheader.enabled'] = dnt is not None and str(dnt) == '1'
gpc = config.get('navigator.globalPrivacyControl')
firefox_user_prefs['privacy.globalprivacycontrol.enabled'] = bool(gpc) if gpc is not None else False
# Accept-Encoding: stock Firefox advertises "gzip, deflate, br, zstd" over
# https and only "gzip, deflate" over http; a forced header value is sent
# on both (measured 2026-09-14: br/zstd on a plain-http echo). Firefox's
# own value is already what the identity claims, so the generated header
# is dropped unless the caller set it.
if not _user_set_accept_encoding:
config.pop('headers.Accept-Encoding', None)
# 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
# Glyph-advance perturbation is OFF by default (seed 0): it moves every
# measured text width off the value the same font produces on a real
# machine (measured 2026-09-14: +1 px per ~100 glyphs, fractional deltas
# on every measureText), which is a fingerprint no stock Firefox emits.
# Pass fonts:spacing_seed explicitly to opt back in.
set_into(config, 'fonts:spacing_seed', 0)
# audio/canvas noise seeds follow the identity: a returning "same device"
# must reproduce its audio and canvas hashes (#442/#765). Derived, not
# equal, so the two streams differ; never 0 (0 disables the noise).
_ident = identity_seed(config)
set_into(config, 'audio:seed', ((_ident * 2654435761 + 97) & 0xFFFFFFFF) or 1)
set_into(config, 'canvas:seed', ((_ident * 40503 + 12345) & 0xFFFFFFFF) or 1)
# Set geolocation
if geoip:
@@ -968,6 +1175,16 @@ def launch_options(
else:
config[key] = value
# A page that receives a position without a prompt must also see
# permissions.query({name: 'geolocation'}) report "granted" -- that is
# what a real Firefox with a stored site grant does. The C++ auto-grant
# alone delivers the fix while the Permissions API still says "prompt",
# which is an incoherence a page can test (daijro/camoufox#769). The
# allow-by-default pref is the same state a user creates by choosing
# "Always allow", so both APIs agree without any per-site permission.
if 'geolocation:latitude' in config and 'geolocation:longitude' in config:
firefox_user_prefs.setdefault('permissions.default.geo', 1)
# 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 (
@@ -981,6 +1198,29 @@ def launch_options(
if locale:
handle_locales(locale, config)
# Select the browser's UI locale to match the Intl locale. Every
# package bakes in Firefox's language packs as packaged locales
# (scripts/inject-locales.py); without this pref the browser stays en-US, so
# a spoofed fr-FR localizes Intl/number/date formatting while
# input.validationMessage and XML parse errors stay English -- a mix no real
# Firefox produces (a Mozilla fr build localizes both). Gecko negotiates the
# value against the packaged locales exactly as a localized build does
# (fr-FR -> fr, pt-BR -> pt-BR), falling back to en-US. Always set: an EMPTY
# value would follow the host OS locale now that more than en-US is packaged.
if config.get('locale:language'):
requested = '-'.join(
part
for part in (
config['locale:language'],
config.get('locale:script'),
config.get('locale:region'),
)
if part
)
else:
requested = 'en-US'
firefox_user_prefs.setdefault('intl.locale.requested', requested)
# Pass the humanize option
if humanize:
set_into(config, 'humanize', True)
@@ -1014,17 +1254,18 @@ def launch_options(
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)
webgl_fp = sample_webgl(target_os, *webgl_config, seed=identity_seed(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'])
webgl_fp = sample_webgl(target_os, config['webGl:vendor'], config['webGl:renderer'], seed=identity_seed(config))
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')
target_os, config.get('screen.width'), config.get('screen.height'),
seed=identity_seed(config),
)
enable_webgl2 = webgl_fp.pop('webGl2Enabled')
@@ -1055,6 +1296,7 @@ def launch_options(
# Prepare environment variables to pass to Camoufox
env_vars = {
**get_env_vars(config, target_os, path=executable_path),
**get_pref_env_vars(firefox_user_prefs),
**env,
}
# Prepare the executable path
+672
View File
@@ -0,0 +1,672 @@
{
"win": {
"base": [],
"langPacks": {
"en-US": {
"oneCore": [
"Microsoft David - English (United States):en-US:local",
"Microsoft Mark - English (United States):en-US:local",
"Microsoft Zira - English (United States):en-US:local"
],
"desktop": [
"Microsoft David Desktop - English (United States):en-US:local",
"Microsoft Zira Desktop - English (United States):en-US:local"
],
"desktopProb": 1,
"weight": 0,
"provenance": "captured"
},
"en-GB": {
"oneCore": [
"Microsoft George - English (United Kingdom):en-GB:local",
"Microsoft Hazel - English (United Kingdom):en-GB:local",
"Microsoft Susan - English (United Kingdom):en-GB:local"
],
"desktop": [
"Microsoft Hazel Desktop - English (Great Britain):en-GB:local"
],
"desktopProb": 0.2,
"weight": 0.1,
"provenance": "captured"
},
"en-AU": {
"oneCore": [
"Microsoft Catherine - English (Australia):en-AU:local",
"Microsoft James - English (Australia):en-AU:local"
],
"weight": 0.02,
"provenance": "captured"
},
"en-CA": {
"oneCore": [
"Microsoft Linda - English (Canada):en-CA:local",
"Microsoft Richard - English (Canada):en-CA:local"
],
"weight": 0.02,
"provenance": "captured"
},
"en-IE": {
"oneCore": [
"Microsoft Sean - English (Ireland):en-IE:local"
],
"weight": 0.01,
"provenance": "captured"
},
"en-IN": {
"oneCore": [
"Microsoft Heera - English (India):en-IN:local",
"Microsoft Ravi - English (India):en-IN:local"
],
"weight": 0.04,
"provenance": "captured"
},
"de-DE": {
"oneCore": [
"Microsoft Hedda - German (Germany):de-DE:local",
"Microsoft Katja - German (Germany):de-DE:local",
"Microsoft Stefan - German (Germany):de-DE:local"
],
"desktop": [
"Microsoft Hedda Desktop - German:de-DE:local"
],
"desktopProb": 0.2,
"weight": 0.1,
"provenance": "captured"
},
"fr-FR": {
"oneCore": [
"Microsoft Hortense - French (France):fr-FR:local",
"Microsoft Julie - French (France):fr-FR:local",
"Microsoft Paul - French (France):fr-FR:local"
],
"desktop": [
"Microsoft Hortense Desktop - French:fr-FR:local"
],
"desktopProb": 0.2,
"weight": 0.1,
"provenance": "captured"
},
"es-ES": {
"oneCore": [
"Microsoft Helena - Spanish (Spain):es-ES:local",
"Microsoft Laura - Spanish (Spain):es-ES:local",
"Microsoft Pablo - Spanish (Spain):es-ES:local"
],
"desktop": [
"Microsoft Helena Desktop - Spanish (Spain):es-ES:local"
],
"desktopProb": 0.2,
"weight": 0.1,
"provenance": "captured"
},
"es-MX": {
"oneCore": [
"Microsoft Raul - Spanish (Mexico):es-MX:local",
"Microsoft Sabina - Spanish (Mexico):es-MX:local"
],
"desktop": [
"Microsoft Sabina Desktop - Spanish (Mexico):es-MX:local"
],
"desktopProb": 0.2,
"weight": 0.05,
"provenance": "captured"
},
"it-IT": {
"oneCore": [
"Microsoft Cosimo - Italian (Italy):it-IT:local",
"Microsoft Elsa - Italian (Italy):it-IT:local"
],
"desktop": [
"Microsoft Elsa Desktop - Italian (Italy):it-IT:local"
],
"desktopProb": 0.2,
"weight": 0.06,
"provenance": "captured"
},
"pt-BR": {
"oneCore": [
"Microsoft Daniel - Portuguese (Brazil):pt-BR:local",
"Microsoft Maria - Portuguese (Brazil):pt-BR:local"
],
"desktop": [
"Microsoft Maria Desktop - Portuguese(Brazil):pt-BR:local"
],
"desktopProb": 0.2,
"weight": 0.05,
"provenance": "captured"
},
"pt-PT": {
"oneCore": [
"Microsoft Helia - Portuguese (Portugal):pt-PT:local"
],
"weight": 0.02,
"provenance": "captured"
},
"nl-NL": {
"oneCore": [
"Microsoft Frank - Dutch (Netherlands):nl-NL:local"
],
"weight": 0.04,
"provenance": "captured"
},
"pl-PL": {
"oneCore": [
"Microsoft Adam - Polish (Poland):pl-PL:local",
"Microsoft Paulina - Polish (Poland):pl-PL:local"
],
"desktop": [
"Microsoft Paulina Desktop - Polish:pl-PL:local"
],
"desktopProb": 0.2,
"weight": 0.04,
"provenance": "captured"
},
"cs-CZ": {
"oneCore": [
"Microsoft Jakub - Czech (Czech Republic):cs-CZ:local"
],
"weight": 0.02,
"provenance": "captured"
},
"el-GR": {
"oneCore": [
"Microsoft Stefanos - Greek (Greece):el-GR:local"
],
"weight": 0.015,
"provenance": "captured"
},
"he-IL": {
"oneCore": [
"Microsoft Asaf - Hebrew (Israel):he-IL:local"
],
"weight": 0.015,
"provenance": "captured"
},
"ar-SA": {
"oneCore": [
"Microsoft Naayf - Arabic (Saudi):ar-SA:local"
],
"weight": 0.03,
"provenance": "captured"
},
"tr-TR": {
"oneCore": [
"Microsoft Tolga - Turkish (Turkey):tr-TR:local"
],
"weight": 0.03,
"provenance": "captured"
},
"zh-CN": {
"oneCore": [
"Microsoft Huihui - Chinese (Simplified, PRC):zh-CN:local",
"Microsoft Kangkang - Chinese (Simplified, PRC):zh-CN:local",
"Microsoft Yaoyao - Chinese (Simplified, PRC):zh-CN:local"
],
"desktop": [
"Microsoft Huihui Desktop - Chinese (Simplified):zh-CN:local"
],
"desktopProb": 0.2,
"weight": 0,
"fontBundle": "cjk-sc",
"provenance": "captured"
},
"ja-JP": {
"oneCore": [
"Microsoft Ayumi - Japanese (Japan):ja-JP:local",
"Microsoft Haruka - Japanese (Japan):ja-JP:local",
"Microsoft Ichiro - Japanese (Japan):ja-JP:local"
],
"weight": 0,
"fontBundle": "jp",
"provenance": "derived"
},
"ko-KR": {
"oneCore": [
"Microsoft Heami - Korean (Korea):ko-KR:local"
],
"weight": 0,
"fontBundle": "kr",
"provenance": "derived"
},
"zh-TW": {
"oneCore": [
"Microsoft Hanhan - Chinese (Traditional, Taiwan):zh-TW:local",
"Microsoft Yating - Chinese (Traditional, Taiwan):zh-TW:local",
"Microsoft Zhiwei - Chinese (Traditional, Taiwan):zh-TW:local"
],
"weight": 0,
"fontBundle": "cjk-tc",
"provenance": "derived"
}
},
"fallbackLocale": "en-US",
"additions": [
{
"id": "extra-english",
"kind": "groups",
"prob": 0.35,
"source": "os-language-pack",
"groups": [
"en-US"
]
},
{
"id": "extra-languages",
"kind": "groups",
"prob": 0.14,
"source": "os-language-pack",
"sizes": [
{
"n": 1,
"w": 0.72
},
{
"n": 2,
"w": 0.22
},
{
"n": 3,
"w": 0.06
}
],
"groups": [
"en-GB",
"en-AU",
"en-CA",
"en-IE",
"en-IN",
"de-DE",
"fr-FR",
"es-ES",
"es-MX",
"it-IT",
"pt-BR",
"pt-PT",
"nl-NL",
"pl-PL",
"cs-CZ",
"el-GR",
"he-IL",
"ar-SA",
"tr-TR"
]
},
{
"id": "third-party-sapi",
"kind": "bundle",
"prob": 0,
"source": "third-party",
"deferred": true,
"voices": [],
"note": "Third-party SAPI5 engines (eSpeak NG's SAPI installer, RHVoice, CereProc, Acapela) do register tokens Firefox would enumerate, but they are rare enough to be near-unique identifiers on their own, and we have no captured display strings for them. Left at prob 0."
}
]
},
"mac": {
"base": [
"Albert:en-US:local",
"Alice:it-IT:local",
"Alva:sv-SE:local",
"Amélie:fr-CA:local",
"Amira:ms-MY:local",
"Anna:de-DE:local",
"Bad News:en-US:local",
"Bahh:en-US:local",
"Bells:en-US:local",
"Boing:en-US:local",
"Bubbles:en-US:local",
"Carmit:he-IL:local",
"Cellos:en-US:local",
"Damayanti:id-ID:local",
"Daniel:en-GB:local",
"Daria:bg-BG:local",
"Wobble:en-US:local",
"Eddy (German (Germany)):de-DE:local",
"Eddy (English (UK)):en-GB:local",
"Eddy (English (US)):en-US:local",
"Eddy (Spanish (Spain)):es-ES:local",
"Eddy (Spanish (Mexico)):es-MX:local",
"Eddy (Finnish (Finland)):fi-FI:local",
"Eddy (French (Canada)):fr-CA:local",
"Eddy (French (France)):fr-FR:local",
"Eddy (Italian (Italy)):it-IT:local",
"Eddy (Japanese (Japan)):ja-JP:local",
"Eddy (Korean (South Korea)):ko-KR:local",
"Eddy (Portuguese (Brazil)):pt-BR:local",
"Eddy (Chinese (China mainland)):zh-CN:local",
"Eddy (Chinese (Taiwan)):zh-TW:local",
"Ellen:nl-BE:local",
"Flo (German (Germany)):de-DE:local",
"Flo (English (UK)):en-GB:local",
"Flo (English (US)):en-US:local",
"Flo (Spanish (Spain)):es-ES:local",
"Flo (Spanish (Mexico)):es-MX:local",
"Flo (Finnish (Finland)):fi-FI:local",
"Flo (French (Canada)):fr-CA:local",
"Flo (French (France)):fr-FR:local",
"Flo (Italian (Italy)):it-IT:local",
"Flo (Japanese (Japan)):ja-JP:local",
"Flo (Korean (South Korea)):ko-KR:local",
"Flo (Portuguese (Brazil)):pt-BR:local",
"Flo (Chinese (China mainland)):zh-CN:local",
"Flo (Chinese (Taiwan)):zh-TW:local",
"Fred:en-US:local",
"Good News:en-US:local",
"Grandma (German (Germany)):de-DE:local",
"Grandma (English (UK)):en-GB:local",
"Grandma (English (US)):en-US:local",
"Grandma (Spanish (Spain)):es-ES:local",
"Grandma (Spanish (Mexico)):es-MX:local",
"Grandma (Finnish (Finland)):fi-FI:local",
"Grandma (French (Canada)):fr-CA:local",
"Grandma (French (France)):fr-FR:local",
"Grandma (Italian (Italy)):it-IT:local",
"Grandma (Japanese (Japan)):ja-JP:local",
"Grandma (Korean (South Korea)):ko-KR:local",
"Grandma (Portuguese (Brazil)):pt-BR:local",
"Grandma (Chinese (China mainland)):zh-CN:local",
"Grandma (Chinese (Taiwan)):zh-TW:local",
"Grandpa (German (Germany)):de-DE:local",
"Grandpa (English (UK)):en-GB:local",
"Grandpa (English (US)):en-US:local",
"Grandpa (Spanish (Spain)):es-ES:local",
"Grandpa (Spanish (Mexico)):es-MX:local",
"Grandpa (Finnish (Finland)):fi-FI:local",
"Grandpa (French (Canada)):fr-CA:local",
"Grandpa (French (France)):fr-FR:local",
"Grandpa (Italian (Italy)):it-IT:local",
"Grandpa (Japanese (Japan)):ja-JP:local",
"Grandpa (Korean (South Korea)):ko-KR:local",
"Grandpa (Portuguese (Brazil)):pt-BR:local",
"Grandpa (Chinese (China mainland)):zh-CN:local",
"Grandpa (Chinese (Taiwan)):zh-TW:local",
"Jester:en-US:local",
"Ioana:ro-RO:local",
"Jacques:fr-FR:local",
"Joana:pt-PT:local",
"Junior:en-US:local",
"Kanya:th-TH:local",
"Karen:en-AU:local",
"Kathy:en-US:local",
"Kyoko:ja-JP:local",
"Lana:hr-HR:local",
"Laura:sk-SK:local",
"Lekha:hi-IN:local",
"Lesya:uk-UA:local",
"Linh:vi-VN:local",
"Luciana:pt-BR:local",
"Majed:ar-001:local",
"Tünde:hu-HU:local",
"Meijia:zh-TW:local",
"Melina:el-GR:local",
"Milena:ru-RU:local",
"Moira:en-IE:local",
"Mónica:es-ES:local",
"Montse:ca-ES:local",
"Nora:nb-NO:local",
"Organ:en-US:local",
"Paulina:es-MX:local",
"Superstar:en-US:local",
"Ralph:en-US:local",
"Reed (German (Germany)):de-DE:local",
"Reed (English (UK)):en-GB:local",
"Reed (English (US)):en-US:local",
"Reed (Spanish (Spain)):es-ES:local",
"Reed (Spanish (Mexico)):es-MX:local",
"Reed (Finnish (Finland)):fi-FI:local",
"Reed (French (Canada)):fr-CA:local",
"Reed (Italian (Italy)):it-IT:local",
"Reed (Japanese (Japan)):ja-JP:local",
"Reed (Korean (South Korea)):ko-KR:local",
"Reed (Portuguese (Brazil)):pt-BR:local",
"Reed (Chinese (China mainland)):zh-CN:local",
"Reed (Chinese (Taiwan)):zh-TW:local",
"Rishi:en-IN:local",
"Rocko (German (Germany)):de-DE:local",
"Rocko (English (UK)):en-GB:local",
"Rocko (English (US)):en-US:local",
"Rocko (Spanish (Spain)):es-ES:local",
"Rocko (Spanish (Mexico)):es-MX:local",
"Rocko (Finnish (Finland)):fi-FI:local",
"Rocko (French (Canada)):fr-CA:local",
"Rocko (French (France)):fr-FR:local",
"Rocko (Italian (Italy)):it-IT:local",
"Rocko (Japanese (Japan)):ja-JP:local",
"Rocko (Korean (South Korea)):ko-KR:local",
"Rocko (Portuguese (Brazil)):pt-BR:local",
"Rocko (Chinese (China mainland)):zh-CN:local",
"Rocko (Chinese (Taiwan)):zh-TW:local",
"Samantha:en-US:local",
"Sandy (German (Germany)):de-DE:local",
"Sandy (English (UK)):en-GB:local",
"Sandy (English (US)):en-US:local",
"Sandy (Spanish (Spain)):es-ES:local",
"Sandy (Spanish (Mexico)):es-MX:local",
"Sandy (Finnish (Finland)):fi-FI:local",
"Sandy (French (Canada)):fr-CA:local",
"Sandy (French (France)):fr-FR:local",
"Sandy (Italian (Italy)):it-IT:local",
"Sandy (Japanese (Japan)):ja-JP:local",
"Sandy (Korean (South Korea)):ko-KR:local",
"Sandy (Portuguese (Brazil)):pt-BR:local",
"Sandy (Chinese (China mainland)):zh-CN:local",
"Sandy (Chinese (Taiwan)):zh-TW:local",
"Sara:da-DK:local",
"Satu:fi-FI:local",
"Shelley (German (Germany)):de-DE:local",
"Shelley (English (UK)):en-GB:local",
"Shelley (English (US)):en-US:local",
"Shelley (Spanish (Spain)):es-ES:local",
"Shelley (Spanish (Mexico)):es-MX:local",
"Shelley (Finnish (Finland)):fi-FI:local",
"Shelley (French (Canada)):fr-CA:local",
"Shelley (French (France)):fr-FR:local",
"Shelley (Italian (Italy)):it-IT:local",
"Shelley (Japanese (Japan)):ja-JP:local",
"Shelley (Korean (South Korea)):ko-KR:local",
"Shelley (Portuguese (Brazil)):pt-BR:local",
"Shelley (Chinese (China mainland)):zh-CN:local",
"Shelley (Chinese (Taiwan)):zh-TW:local",
"Sinji:zh-HK:local",
"Tessa:en-ZA:local",
"Thomas:fr-FR:local",
"Tina:sl-SI:local",
"Tingting:zh-CN:local",
"Trinoids:en-US:local",
"Whisper:en-US:local",
"Xander:nl-NL:local",
"Yelda:tr-TR:local",
"Yuna:ko-KR:local",
"Zarvox:en-US:local",
"Zosia:pl-PL:local",
"Zuzana:cs-CZ:local"
],
"additions": [
{
"id": "macintalk-download",
"kind": "alacarte",
"prob": 0.12,
"source": "os-download",
"sizes": [
{
"n": 1,
"w": 0.62
},
{
"n": 2,
"w": 0.26
},
{
"n": 3,
"w": 0.09
},
{
"n": 4,
"w": 0.03
}
],
"voices": [
"Agnes:en-US:local",
"Alex:en-US:local",
"Bruce:en-US:local",
"Vicki:en-US:local"
]
},
{
"id": "vocalizer-enhanced",
"kind": "alacarte",
"prob": 0,
"source": "os-download",
"deferred": true,
"voices": [],
"note": "The 41 Enhanced/Premium voices are deferred. Real names and languages are known from Apple's asset catalog; the runtime voiceURI identifier is not, and inventing it would be a tell."
}
]
},
"lin": {
"base": [
"Afrikaans:af:local",
"Amharic:am:local",
"Aragonese:an:local",
"Arabic:ar:local",
"Assamese:as:local",
"Azerbaijani:az:local",
"Bashkir:ba:local",
"Belarusian:be:local",
"Bulgarian:bg:local",
"Bengali:bn:local",
"Bishnupriya Manipuri:bpy:local",
"Bosnian:bs:local",
"Catalan:ca:local",
"Cherokee:chr-US-QAAA-X-WEST:local",
"Chinese (Mandarin, latin as English):cmn:local",
"Chinese (Mandarin, latin as Pinyin):cmn-LATN-PINYIN:local",
"Czech:cs:local",
"Chuvash:cv:local",
"Welsh:cy:local",
"Danish:da:local",
"German:de:local",
"Greek:el:local",
"English (Caribbean):en-029:local",
"English (Great Britain):en-GB:local",
"English (Scotland):en-GB-SCOTLAND:local",
"English (Lancaster):en-GB-X-GBCLAN:local",
"English (West Midlands):en-GB-X-GBCWMD:local",
"English (Received Pronunciation):en-GB-X-RP:local",
"English (America):en-US:local",
"English (America, New York City):en-US-NYC:local",
"Esperanto:eo:local",
"Spanish (Spain):es:local",
"Spanish (Latin America):es-419:local",
"Estonian:et:local",
"Basque:eu:local",
"Persian:fa:local",
"Persian (Pinglish):fa-LATN:local",
"Finnish:fi:local",
"French (Belgium):fr-BE:local",
"French (Switzerland):fr-CH:local",
"French (France):fr-FR:local",
"Gaelic (Irish):ga:local",
"Gaelic (Scottish):gd:local",
"Guarani:gn:local",
"Greek (Ancient):grc:local",
"Gujarati:gu:local",
"Hakka Chinese:hak:local",
"Hawaiian:haw:local",
"Hebrew:he:local",
"Hindi:hi:local",
"Croatian:hr:local",
"Haitian Creole:ht:local",
"Hungarian:hu:local",
"Armenian (East Armenia):hy:local",
"Armenian (West Armenia):hyw:local",
"Interlingua:ia:local",
"Indonesian:id:local",
"Ido:io:local",
"Icelandic:is:local",
"Italian:it:local",
"Japanese:ja:local",
"Lojban:jbo:local",
"Georgian:ka:local",
"Kazakh:kk:local",
"Greenlandic:kl:local",
"Kannada:kn:local",
"Korean:ko:local",
"Konkani:kok:local",
"Kurdish:ku:local",
"Kyrgyz:ky:local",
"Latin:la:local",
"Luxembourgish:lb:local",
"Lingua Franca Nova:lfn:local",
"Lithuanian:lt:local",
"Latgalian:ltg:local",
"Latvian:lv:local",
"Māori:mi:local",
"Macedonian:mk:local",
"Malayalam:ml:local",
"Marathi:mr:local",
"Malay:ms:local",
"Maltese:mt:local",
"Myanmar (Burmese):my:local",
"Norwegian Bokmål:nb:local",
"Nahuatl (Classical):nci:local",
"Nepali:ne:local",
"Dutch:nl:local",
"Nogai:nog:local",
"Oromo:om:local",
"Oriya:or:local",
"Punjabi:pa:local",
"Papiamento:pap:local",
"Klingon:piqd:local",
"Polish:pl:local",
"Portuguese (Portugal):pt:local",
"Portuguese (Brazil):pt-BR:local",
"Pyash:py:local",
"Lang_Belta:qdb:local",
"Quechua:qu:local",
"K'iche':quc:local",
"Quenya:qya:local",
"Romanian:ro:local",
"Russian:ru:local",
"Russian (Latvia):ru-LV:local",
"Sindhi:sd:local",
"Shan (Tai Yai):shn:local",
"Sinhala:si:local",
"Sindarin:sjn:local",
"Slovak:sk:local",
"Slovenian:sl:local",
"Lule Saami:smj:local",
"Albanian:sq:local",
"Serbian:sr:local",
"Swedish:sv:local",
"Swahili:sw:local",
"Tamil:ta:local",
"Telugu:te:local",
"Thai:th:local",
"Turkmen:tk:local",
"Setswana:tn:local",
"Turkish:tr:local",
"Tatar:tt:local",
"Uyghur:ug:local",
"Ukrainian:uk:local",
"Urdu:ur:local",
"Uzbek:uz:local",
"Vietnamese (Northern):vi:local",
"Vietnamese (Central):vi-VN-X-CENTRAL:local",
"Vietnamese (Southern):vi-VN-X-SOUTH:local",
"Chinese (Cantonese):yue:local",
"Chinese (Cantonese, latin as Jyutping):yue:local"
],
"additions": [
{
"id": "alt-speechd-modules",
"kind": "bundle",
"prob": 0,
"source": "third-party",
"deferred": true,
"voices": [],
"note": "Alternative speech-dispatcher modules (festival, flite, pico, RHVoice, mbrola) each add their own named voices. All are optional packages almost nobody installs on a desktop, and we have no captured names, so the espeak-ng list ships alone."
}
]
}
}
+188
View File
@@ -0,0 +1,188 @@
{
"mac": {
"Albert|en-US": "urn:moz-tts:osx:com.apple.speech.synthesis.voice.Albert",
"Alice|it-IT": "urn:moz-tts:osx:com.apple.voice.compact.it-IT.Alice",
"Alva|sv-SE": "urn:moz-tts:osx:com.apple.voice.compact.sv-SE.Alva",
"Aman|en-IN": "urn:moz-tts:osx:com.apple.voice.Aman",
"Amira|ms-MY": "urn:moz-tts:osx:com.apple.voice.compact.ms-MY.Amira",
"Amélie|fr-CA": "urn:moz-tts:osx:com.apple.voice.compact.fr-CA.Amelie",
"Anna|de-DE": "urn:moz-tts:osx:com.apple.voice.compact.de-DE.Anna",
"Aru|kk-KZ": "urn:moz-tts:osx:com.apple.voice.Aru",
"Bad News|en-US": "urn:moz-tts:osx:com.apple.speech.synthesis.voice.BadNews",
"Bahh|en-US": "urn:moz-tts:osx:com.apple.speech.synthesis.voice.Bahh",
"Bells|en-US": "urn:moz-tts:osx:com.apple.speech.synthesis.voice.Bells",
"Boing|en-US": "urn:moz-tts:osx:com.apple.speech.synthesis.voice.Boing",
"Bubbles|en-US": "urn:moz-tts:osx:com.apple.speech.synthesis.voice.Bubbles",
"Carmit|he-IL": "urn:moz-tts:osx:com.apple.voice.compact.he-IL.Carmit",
"Cellos|en-US": "urn:moz-tts:osx:com.apple.speech.synthesis.voice.Cellos",
"Damayanti|id-ID": "urn:moz-tts:osx:com.apple.voice.compact.id-ID.Damayanti",
"Daniel|en-GB": "urn:moz-tts:osx:com.apple.voice.compact.en-GB.Daniel",
"Daria|bg-BG": "urn:moz-tts:osx:com.apple.voice.compact.bg-BG.Daria",
"Eddy (Chinese (China mainland))|zh-CN": "urn:moz-tts:osx:com.apple.eloquence.zh-CN.Eddy",
"Eddy (Chinese (Taiwan))|zh-TW": "urn:moz-tts:osx:com.apple.eloquence.zh-TW.Eddy",
"Eddy (English (UK))|en-GB": "urn:moz-tts:osx:com.apple.eloquence.en-GB.Eddy",
"Eddy (English (US))|en-US": "urn:moz-tts:osx:com.apple.eloquence.en-US.Eddy",
"Eddy (Finnish (Finland))|fi-FI": "urn:moz-tts:osx:com.apple.eloquence.fi-FI.Eddy",
"Eddy (French (Canada))|fr-CA": "urn:moz-tts:osx:com.apple.eloquence.fr-CA.Eddy",
"Eddy (French (France))|fr-FR": "urn:moz-tts:osx:com.apple.eloquence.fr-FR.Eddy",
"Eddy (German (Germany))|de-DE": "urn:moz-tts:osx:com.apple.eloquence.de-DE.Eddy",
"Eddy (Italian (Italy))|it-IT": "urn:moz-tts:osx:com.apple.eloquence.it-IT.Eddy",
"Eddy (Japanese (Japan))|ja-JP": "urn:moz-tts:osx:com.apple.eloquence.ja-JP.Eddy",
"Eddy (Korean (South Korea))|ko-KR": "urn:moz-tts:osx:com.apple.eloquence.ko-KR.Eddy",
"Eddy (Portuguese (Brazil))|pt-BR": "urn:moz-tts:osx:com.apple.eloquence.pt-BR.Eddy",
"Eddy (Spanish (Mexico))|es-MX": "urn:moz-tts:osx:com.apple.eloquence.es-MX.Eddy",
"Eddy (Spanish (Spain))|es-ES": "urn:moz-tts:osx:com.apple.eloquence.es-ES.Eddy",
"Ellen|nl-BE": "urn:moz-tts:osx:com.apple.voice.compact.nl-BE.Ellen",
"Flo (Chinese (China mainland))|zh-CN": "urn:moz-tts:osx:com.apple.eloquence.zh-CN.Flo",
"Flo (Chinese (Taiwan))|zh-TW": "urn:moz-tts:osx:com.apple.eloquence.zh-TW.Flo",
"Flo (English (UK))|en-GB": "urn:moz-tts:osx:com.apple.eloquence.en-GB.Flo",
"Flo (English (US))|en-US": "urn:moz-tts:osx:com.apple.eloquence.en-US.Flo",
"Flo (Finnish (Finland))|fi-FI": "urn:moz-tts:osx:com.apple.eloquence.fi-FI.Flo",
"Flo (French (Canada))|fr-CA": "urn:moz-tts:osx:com.apple.eloquence.fr-CA.Flo",
"Flo (French (France))|fr-FR": "urn:moz-tts:osx:com.apple.eloquence.fr-FR.Flo",
"Flo (German (Germany))|de-DE": "urn:moz-tts:osx:com.apple.eloquence.de-DE.Flo",
"Flo (Italian (Italy))|it-IT": "urn:moz-tts:osx:com.apple.eloquence.it-IT.Flo",
"Flo (Japanese (Japan))|ja-JP": "urn:moz-tts:osx:com.apple.eloquence.ja-JP.Flo",
"Flo (Korean (South Korea))|ko-KR": "urn:moz-tts:osx:com.apple.eloquence.ko-KR.Flo",
"Flo (Portuguese (Brazil))|pt-BR": "urn:moz-tts:osx:com.apple.eloquence.pt-BR.Flo",
"Flo (Spanish (Mexico))|es-MX": "urn:moz-tts:osx:com.apple.eloquence.es-MX.Flo",
"Flo (Spanish (Spain))|es-ES": "urn:moz-tts:osx:com.apple.eloquence.es-ES.Flo",
"Fred|en-US": "urn:moz-tts:osx:com.apple.speech.synthesis.voice.Fred",
"Geeta|te-IN": "urn:moz-tts:osx:com.apple.voice.compact.te-IN.Geeta",
"Good News|en-US": "urn:moz-tts:osx:com.apple.speech.synthesis.voice.GoodNews",
"Grandma (Chinese (China mainland))|zh-CN": "urn:moz-tts:osx:com.apple.eloquence.zh-CN.Grandma",
"Grandma (Chinese (Taiwan))|zh-TW": "urn:moz-tts:osx:com.apple.eloquence.zh-TW.Grandma",
"Grandma (English (UK))|en-GB": "urn:moz-tts:osx:com.apple.eloquence.en-GB.Grandma",
"Grandma (English (US))|en-US": "urn:moz-tts:osx:com.apple.eloquence.en-US.Grandma",
"Grandma (Finnish (Finland))|fi-FI": "urn:moz-tts:osx:com.apple.eloquence.fi-FI.Grandma",
"Grandma (French (Canada))|fr-CA": "urn:moz-tts:osx:com.apple.eloquence.fr-CA.Grandma",
"Grandma (French (France))|fr-FR": "urn:moz-tts:osx:com.apple.eloquence.fr-FR.Grandma",
"Grandma (German (Germany))|de-DE": "urn:moz-tts:osx:com.apple.eloquence.de-DE.Grandma",
"Grandma (Italian (Italy))|it-IT": "urn:moz-tts:osx:com.apple.eloquence.it-IT.Grandma",
"Grandma (Japanese (Japan))|ja-JP": "urn:moz-tts:osx:com.apple.eloquence.ja-JP.Grandma",
"Grandma (Korean (South Korea))|ko-KR": "urn:moz-tts:osx:com.apple.eloquence.ko-KR.Grandma",
"Grandma (Portuguese (Brazil))|pt-BR": "urn:moz-tts:osx:com.apple.eloquence.pt-BR.Grandma",
"Grandma (Spanish (Mexico))|es-MX": "urn:moz-tts:osx:com.apple.eloquence.es-MX.Grandma",
"Grandma (Spanish (Spain))|es-ES": "urn:moz-tts:osx:com.apple.eloquence.es-ES.Grandma",
"Grandpa (Chinese (China mainland))|zh-CN": "urn:moz-tts:osx:com.apple.eloquence.zh-CN.Grandpa",
"Grandpa (Chinese (Taiwan))|zh-TW": "urn:moz-tts:osx:com.apple.eloquence.zh-TW.Grandpa",
"Grandpa (English (UK))|en-GB": "urn:moz-tts:osx:com.apple.eloquence.en-GB.Grandpa",
"Grandpa (English (US))|en-US": "urn:moz-tts:osx:com.apple.eloquence.en-US.Grandpa",
"Grandpa (Finnish (Finland))|fi-FI": "urn:moz-tts:osx:com.apple.eloquence.fi-FI.Grandpa",
"Grandpa (French (Canada))|fr-CA": "urn:moz-tts:osx:com.apple.eloquence.fr-CA.Grandpa",
"Grandpa (French (France))|fr-FR": "urn:moz-tts:osx:com.apple.eloquence.fr-FR.Grandpa",
"Grandpa (German (Germany))|de-DE": "urn:moz-tts:osx:com.apple.eloquence.de-DE.Grandpa",
"Grandpa (Italian (Italy))|it-IT": "urn:moz-tts:osx:com.apple.eloquence.it-IT.Grandpa",
"Grandpa (Japanese (Japan))|ja-JP": "urn:moz-tts:osx:com.apple.eloquence.ja-JP.Grandpa",
"Grandpa (Korean (South Korea))|ko-KR": "urn:moz-tts:osx:com.apple.eloquence.ko-KR.Grandpa",
"Grandpa (Portuguese (Brazil))|pt-BR": "urn:moz-tts:osx:com.apple.eloquence.pt-BR.Grandpa",
"Grandpa (Spanish (Mexico))|es-MX": "urn:moz-tts:osx:com.apple.eloquence.es-MX.Grandpa",
"Grandpa (Spanish (Spain))|es-ES": "urn:moz-tts:osx:com.apple.eloquence.es-ES.Grandpa",
"Ioana|ro-RO": "urn:moz-tts:osx:com.apple.voice.compact.ro-RO.Ioana",
"Jacques|fr-FR": "urn:moz-tts:osx:com.apple.eloquence.fr-FR.Jacques",
"Jester|en-US": "urn:moz-tts:osx:com.apple.speech.synthesis.voice.Hysterical",
"Joana|pt-PT": "urn:moz-tts:osx:com.apple.voice.compact.pt-PT.Joana",
"Junior|en-US": "urn:moz-tts:osx:com.apple.speech.synthesis.voice.Junior",
"Kanya|th-TH": "urn:moz-tts:osx:com.apple.voice.compact.th-TH.Kanya",
"Karen|en-AU": "urn:moz-tts:osx:com.apple.voice.compact.en-AU.Karen",
"Kathy|en-US": "urn:moz-tts:osx:com.apple.speech.synthesis.voice.Kathy",
"Kyoko|ja-JP": "urn:moz-tts:osx:com.apple.voice.compact.ja-JP.Kyoko",
"Lana|hr-HR": "urn:moz-tts:osx:com.apple.voice.compact.hr-HR.Lana",
"Laura|sk-SK": "urn:moz-tts:osx:com.apple.voice.compact.sk-SK.Laura",
"Lekha|hi-IN": "urn:moz-tts:osx:com.apple.voice.compact.hi-IN.Lekha",
"Lesya|uk-UA": "urn:moz-tts:osx:com.apple.voice.compact.uk-UA.Lesya",
"Linh|vi-VN": "urn:moz-tts:osx:com.apple.voice.compact.vi-VN.Linh",
"Luciana|pt-BR": "urn:moz-tts:osx:com.apple.voice.compact.pt-BR.Luciana",
"Majed|ar-001": "urn:moz-tts:osx:com.apple.voice.compact.ar-001.Maged",
"Meijia|zh-TW": "urn:moz-tts:osx:com.apple.voice.compact.zh-TW.Meijia",
"Melina|el-GR": "urn:moz-tts:osx:com.apple.voice.compact.el-GR.Melina",
"Milena|ru-RU": "urn:moz-tts:osx:com.apple.voice.compact.ru-RU.Milena",
"Moira|en-IE": "urn:moz-tts:osx:com.apple.voice.compact.en-IE.Moira",
"Montse|ca-ES": "urn:moz-tts:osx:com.apple.voice.compact.ca-ES.Montserrat",
"Mónica|es-ES": "urn:moz-tts:osx:com.apple.voice.compact.es-ES.Monica",
"Nora|nb-NO": "urn:moz-tts:osx:com.apple.voice.compact.nb-NO.Nora",
"Ona|lt-LT": "urn:moz-tts:osx:com.apple.voice.Ona",
"Organ|en-US": "urn:moz-tts:osx:com.apple.speech.synthesis.voice.Organ",
"Paulina|es-MX": "urn:moz-tts:osx:com.apple.voice.compact.es-MX.Paulina",
"Piya|bn-IN": "urn:moz-tts:osx:com.apple.voice.compact.bn-IN.Paya",
"Ralph|en-US": "urn:moz-tts:osx:com.apple.speech.synthesis.voice.Ralph",
"Reed (Chinese (China mainland))|zh-CN": "urn:moz-tts:osx:com.apple.eloquence.zh-CN.Reed",
"Reed (Chinese (Taiwan))|zh-TW": "urn:moz-tts:osx:com.apple.eloquence.zh-TW.Reed",
"Reed (English (UK))|en-GB": "urn:moz-tts:osx:com.apple.eloquence.en-GB.Reed",
"Reed (English (US))|en-US": "urn:moz-tts:osx:com.apple.eloquence.en-US.Reed",
"Reed (Finnish (Finland))|fi-FI": "urn:moz-tts:osx:com.apple.eloquence.fi-FI.Reed",
"Reed (French (Canada))|fr-CA": "urn:moz-tts:osx:com.apple.eloquence.fr-CA.Reed",
"Reed (German (Germany))|de-DE": "urn:moz-tts:osx:com.apple.eloquence.de-DE.Reed",
"Reed (Italian (Italy))|it-IT": "urn:moz-tts:osx:com.apple.eloquence.it-IT.Reed",
"Reed (Japanese (Japan))|ja-JP": "urn:moz-tts:osx:com.apple.eloquence.ja-JP.Reed",
"Reed (Korean (South Korea))|ko-KR": "urn:moz-tts:osx:com.apple.eloquence.ko-KR.Reed",
"Reed (Portuguese (Brazil))|pt-BR": "urn:moz-tts:osx:com.apple.eloquence.pt-BR.Reed",
"Reed (Spanish (Mexico))|es-MX": "urn:moz-tts:osx:com.apple.eloquence.es-MX.Reed",
"Reed (Spanish (Spain))|es-ES": "urn:moz-tts:osx:com.apple.eloquence.es-ES.Reed",
"Rishi|en-IN": "urn:moz-tts:osx:com.apple.voice.compact.en-IN.Rishi",
"Rocko (Chinese (China mainland))|zh-CN": "urn:moz-tts:osx:com.apple.eloquence.zh-CN.Rocko",
"Rocko (Chinese (Taiwan))|zh-TW": "urn:moz-tts:osx:com.apple.eloquence.zh-TW.Rocko",
"Rocko (English (UK))|en-GB": "urn:moz-tts:osx:com.apple.eloquence.en-GB.Rocko",
"Rocko (English (US))|en-US": "urn:moz-tts:osx:com.apple.eloquence.en-US.Rocko",
"Rocko (Finnish (Finland))|fi-FI": "urn:moz-tts:osx:com.apple.eloquence.fi-FI.Rocko",
"Rocko (French (Canada))|fr-CA": "urn:moz-tts:osx:com.apple.eloquence.fr-CA.Rocko",
"Rocko (French (France))|fr-FR": "urn:moz-tts:osx:com.apple.eloquence.fr-FR.Rocko",
"Rocko (German (Germany))|de-DE": "urn:moz-tts:osx:com.apple.eloquence.de-DE.Rocko",
"Rocko (Italian (Italy))|it-IT": "urn:moz-tts:osx:com.apple.eloquence.it-IT.Rocko",
"Rocko (Japanese (Japan))|ja-JP": "urn:moz-tts:osx:com.apple.eloquence.ja-JP.Rocko",
"Rocko (Korean (South Korea))|ko-KR": "urn:moz-tts:osx:com.apple.eloquence.ko-KR.Rocko",
"Rocko (Portuguese (Brazil))|pt-BR": "urn:moz-tts:osx:com.apple.eloquence.pt-BR.Rocko",
"Rocko (Spanish (Mexico))|es-MX": "urn:moz-tts:osx:com.apple.eloquence.es-MX.Rocko",
"Rocko (Spanish (Spain))|es-ES": "urn:moz-tts:osx:com.apple.eloquence.es-ES.Rocko",
"Samantha|en-US": "urn:moz-tts:osx:com.apple.voice.compact.en-US.Samantha",
"Sandy (Chinese (China mainland))|zh-CN": "urn:moz-tts:osx:com.apple.eloquence.zh-CN.Sandy",
"Sandy (Chinese (Taiwan))|zh-TW": "urn:moz-tts:osx:com.apple.eloquence.zh-TW.Sandy",
"Sandy (English (UK))|en-GB": "urn:moz-tts:osx:com.apple.eloquence.en-GB.Sandy",
"Sandy (English (US))|en-US": "urn:moz-tts:osx:com.apple.eloquence.en-US.Sandy",
"Sandy (Finnish (Finland))|fi-FI": "urn:moz-tts:osx:com.apple.eloquence.fi-FI.Sandy",
"Sandy (French (Canada))|fr-CA": "urn:moz-tts:osx:com.apple.eloquence.fr-CA.Sandy",
"Sandy (French (France))|fr-FR": "urn:moz-tts:osx:com.apple.eloquence.fr-FR.Sandy",
"Sandy (German (Germany))|de-DE": "urn:moz-tts:osx:com.apple.eloquence.de-DE.Sandy",
"Sandy (Italian (Italy))|it-IT": "urn:moz-tts:osx:com.apple.eloquence.it-IT.Sandy",
"Sandy (Japanese (Japan))|ja-JP": "urn:moz-tts:osx:com.apple.eloquence.ja-JP.Sandy",
"Sandy (Korean (South Korea))|ko-KR": "urn:moz-tts:osx:com.apple.eloquence.ko-KR.Sandy",
"Sandy (Portuguese (Brazil))|pt-BR": "urn:moz-tts:osx:com.apple.eloquence.pt-BR.Sandy",
"Sandy (Spanish (Mexico))|es-MX": "urn:moz-tts:osx:com.apple.eloquence.es-MX.Sandy",
"Sandy (Spanish (Spain))|es-ES": "urn:moz-tts:osx:com.apple.eloquence.es-ES.Sandy",
"Sara|da-DK": "urn:moz-tts:osx:com.apple.voice.compact.da-DK.Sara",
"Satu|fi-FI": "urn:moz-tts:osx:com.apple.voice.compact.fi-FI.Satu",
"Shelley (Chinese (China mainland))|zh-CN": "urn:moz-tts:osx:com.apple.eloquence.zh-CN.Shelley",
"Shelley (Chinese (Taiwan))|zh-TW": "urn:moz-tts:osx:com.apple.eloquence.zh-TW.Shelley",
"Shelley (English (UK))|en-GB": "urn:moz-tts:osx:com.apple.eloquence.en-GB.Shelley",
"Shelley (English (US))|en-US": "urn:moz-tts:osx:com.apple.eloquence.en-US.Shelley",
"Shelley (Finnish (Finland))|fi-FI": "urn:moz-tts:osx:com.apple.eloquence.fi-FI.Shelley",
"Shelley (French (Canada))|fr-CA": "urn:moz-tts:osx:com.apple.eloquence.fr-CA.Shelley",
"Shelley (French (France))|fr-FR": "urn:moz-tts:osx:com.apple.eloquence.fr-FR.Shelley",
"Shelley (German (Germany))|de-DE": "urn:moz-tts:osx:com.apple.eloquence.de-DE.Shelley",
"Shelley (Italian (Italy))|it-IT": "urn:moz-tts:osx:com.apple.eloquence.it-IT.Shelley",
"Shelley (Japanese (Japan))|ja-JP": "urn:moz-tts:osx:com.apple.eloquence.ja-JP.Shelley",
"Shelley (Korean (South Korea))|ko-KR": "urn:moz-tts:osx:com.apple.eloquence.ko-KR.Shelley",
"Shelley (Portuguese (Brazil))|pt-BR": "urn:moz-tts:osx:com.apple.eloquence.pt-BR.Shelley",
"Shelley (Spanish (Mexico))|es-MX": "urn:moz-tts:osx:com.apple.eloquence.es-MX.Shelley",
"Shelley (Spanish (Spain))|es-ES": "urn:moz-tts:osx:com.apple.eloquence.es-ES.Shelley",
"Sinji|zh-HK": "urn:moz-tts:osx:com.apple.voice.compact.zh-HK.Sinji",
"Soumya|kn-IN": "urn:moz-tts:osx:com.apple.voice.compact.kn-IN.Alpana",
"Superstar|en-US": "urn:moz-tts:osx:com.apple.speech.synthesis.voice.Princess",
"Tara|en-IN": "urn:moz-tts:osx:com.apple.voice.Tara",
"Tessa|en-ZA": "urn:moz-tts:osx:com.apple.voice.compact.en-ZA.Tessa",
"Thomas|fr-FR": "urn:moz-tts:osx:com.apple.voice.compact.fr-FR.Thomas",
"Tina|sl-SI": "urn:moz-tts:osx:com.apple.voice.compact.sl-SI.Tina",
"Tingting|zh-CN": "urn:moz-tts:osx:com.apple.voice.compact.zh-CN.Tingting",
"Trinoids|en-US": "urn:moz-tts:osx:com.apple.speech.synthesis.voice.Trinoids",
"Tünde|hu-HU": "urn:moz-tts:osx:com.apple.voice.compact.hu-HU.Mariska",
"Vani|ta-IN": "urn:moz-tts:osx:com.apple.voice.compact.ta-IN.Vani",
"Whisper|en-US": "urn:moz-tts:osx:com.apple.speech.synthesis.voice.Whisper",
"Wobble|en-US": "urn:moz-tts:osx:com.apple.speech.synthesis.voice.Deranged",
"Xander|nl-NL": "urn:moz-tts:osx:com.apple.voice.compact.nl-NL.Xander",
"Yelda|tr-TR": "urn:moz-tts:osx:com.apple.voice.compact.tr-TR.Yelda",
"Yuna|ko-KR": "urn:moz-tts:osx:com.apple.voice.compact.ko-KR.Yuna",
"Zarvox|en-US": "urn:moz-tts:osx:com.apple.speech.synthesis.voice.Zarvox",
"Zosia|pl-PL": "urn:moz-tts:osx:com.apple.voice.compact.pl-PL.Zosia",
"Zuzana|cs-CZ": "urn:moz-tts:osx:com.apple.voice.compact.cs-CZ.Zuzana"
}
}
+48 -4
View File
@@ -10,9 +10,52 @@ from camoufox.pkgman import OS_ARCH_MATRIX
# Get database path relative to this file
DB_PATH = Path(__file__).parent / 'webgl_data.db'
# Extensions a release Firefox never exposes (draft extensions behind
# webgl.enable-draft-extensions, or mobile-only): some database rows carry
# them, and a spoofed list that names one is a tell on its own. Measured on
# stock Firefox 152.0.4 on real Windows 11 (ANGLE D3D11) 2026-09-16: none of
# these four are exposed. WEBGL_provoking_vertex is NOT in this set: stock
# Firefox on Apple GPUs and on Windows does expose it.
_NEVER_EXPOSED_EXTENSIONS = frozenset(
{
'WEBGL_multi_draw',
'WEBGL_clip_cull_distance',
'EXT_texture_norm16',
'WEBGL_compressed_texture_etc1',
}
)
# OVR_multiview2 is a RELEASE extension whose availability depends on the
# graphics backend. On Windows, Firefox renders WebGL through ANGLE's D3D11
# backend, which implements multiview on every D3D11 GPU: stock 152.0.4 on
# a Windows 11 host exposes it on WebGL2 (MAX_VIEWS_OVR=4, headless and headed), and the
# recorded corpus has it on 13 of the 15 Windows rows with WebGL2 (never on
# WebGL1) -- filtering it there was a leak. On Linux it depends on
# the host GL driver (stock on an NVIDIA box does not expose it), and
# ClientWebGLContext::IsSupported answers from the spoofed list without asking
# the host, so a Linux identity could advertise an extension the GPU cannot
# back; it stays filtered off Windows.
_HOST_DEPENDENT_EXTENSIONS = frozenset({'OVR_multiview2'})
def _filtered_extensions(os: str) -> frozenset:
if os == 'win':
return _NEVER_EXPOSED_EXTENSIONS
return _NEVER_EXPOSED_EXTENSIONS | _HOST_DEPENDENT_EXTENSIONS
def _load_webgl_data(data_str: str, os: str) -> Dict[str, str]:
data = orjson.loads(data_str)
blocked = _filtered_extensions(os)
for key in ('webGl:supportedExtensions', 'webGl2:supportedExtensions'):
exts = data.get(key)
if isinstance(exts, list):
data[key] = [e for e in exts if e not in blocked]
return data
def sample_webgl(
os: str, vendor: Optional[str] = None, renderer: Optional[str] = None
os: str, vendor: Optional[str] = None, renderer: Optional[str] = None, seed: Optional[int] = None
) -> Dict[str, str]:
"""
Sample a random WebGL vendor/renderer combination and its data based on OS probabilities.
@@ -61,7 +104,7 @@ def sample_webgl(
)
conn.close()
return orjson.loads(result[2])
return _load_webgl_data(result[2], os)
# Get all vendor/renderer pairs and their probabilities for this OS
cursor.execute(
@@ -81,10 +124,11 @@ def sample_webgl(
probs_array = probs_array / probs_array.sum()
# Sample based on probabilities
idx = np.random.choice(len(probs_array), p=probs_array)
# Seeded so the same identity always draws the same device (#442/#765).
idx = np.random.default_rng(seed).choice(len(probs_array), p=probs_array)
# Parse the JSON data string
return orjson.loads(data_strs[idx])
return _load_webgl_data(data_strs[idx], os)
def get_possible_pairs() -> Dict[str, List[Tuple[str, str]]]:
+126 -5
View File
@@ -224,15 +224,136 @@ class TestClampWindowPosition:
class TestSetMediaDevicesDefaults:
def test_sets_one_mic_one_cam(self):
c = {}
def test_draws_common_desktop_devices(self):
# A pre-permission page only sees "has a mic" / "has a camera"; the draw
# is seeded by the identity and follows the common desktop population.
c = {"navigator.userAgent": "ua", "navigator.platform": "Win32"}
set_media_devices_defaults(c)
assert c["mediaDevices:enabled"] is True
assert c["mediaDevices:micros"] == 1
assert c["mediaDevices:webcams"] == 1
assert c["mediaDevices:speakers"] == 0
# counts and the post-grant label/group lists agree
for kind, key in (("micros", "microphone"), ("webcams", "webcam"), ("speakers", "speaker")):
n = c[f"mediaDevices:{kind}"]
assert n >= 0
assert len(c[f"mediaDevices:{key}Labels"]) == n
assert len(c[f"mediaDevices:{key}Groups"]) == n
# a Windows machine always has an output, in WASAPI label form
assert c["mediaDevices:speakers"] >= 1
assert all("(" in s for s in c["mediaDevices:speakerLabels"])
again = {"navigator.userAgent": "ua", "navigator.platform": "Win32"}
set_media_devices_defaults(again)
assert again == c
# over many identities laptops dominate Windows: most have both
mics = cams = 0
for i in range(400):
d = {"navigator.userAgent": f"ua{i}", "navigator.platform": "Win32"}
set_media_devices_defaults(d)
mics += d["mediaDevices:micros"] > 0
cams += d["mediaDevices:webcams"] > 0
assert 0.8 < mics / 400 < 1.0
assert 0.55 < cams / 400 < 0.95
def test_per_os_label_style(self):
from camoufox.fingerprints import draw_media_devices
mac = draw_media_devices("mac", 7)
assert all(not s.startswith("Microphone (") for s in mac["mediaDevices:microphoneLabels"])
lin = draw_media_devices("lin", 7)
# PulseAudio lists a monitor source per output as a capture device
outs = lin["mediaDevices:speakerLabels"]
assert all(f"Monitor of {o}" in lin["mediaDevices:microphoneLabels"] for o in outs)
# a sound card's mic and speakers share a group, like a real groupId
win = draw_media_devices("win", 11)
if win["mediaDevices:micros"] and win["mediaDevices:speakers"]:
assert win["mediaDevices:microphoneGroups"][0] == win["mediaDevices:speakerGroups"][0]
# never the fake engine's names
for os_key in ("win", "mac", "lin"):
for seed in range(50):
d = draw_media_devices(os_key, seed)
mics = d["mediaDevices:microphoneLabels"]
cams = d["mediaDevices:webcamLabels"]
assert "Default Audio Device" not in mics
assert "Default Video Device" not in cams
# distinct within a kind (macOS names a webcam and its mic alike)
assert len(set(mics)) == len(mics)
assert len(set(cams)) == len(cams)
def test_respects_user_set_media_devices(self):
c = {"mediaDevices:webcams": 5}
set_media_devices_defaults(c)
assert c == {"mediaDevices:webcams": 5}
class TestFixHardwareConcurrency:
def test_keeps_a_plausible_draw_the_host_can_be_pinned_to(self, monkeypatch):
# The fingerprint's value survives when it is a count a real desktop
# ships with AND the browser can be pinned to it (reported == measurable
# by construction).
from camoufox import cpu_affinity, fingerprints as fp
monkeypatch.setattr(cpu_affinity, "supported", lambda: True)
monkeypatch.setattr(fp, "host_cpu_count", lambda: 16)
for drawn in (4, 6, 8, 10, 12, 14, 16):
c = {"navigator.hardwareConcurrency": drawn}
fp.fix_hardware_concurrency(c)
assert c["navigator.hardwareConcurrency"] == drawn
def test_snaps_implausible_draws_down_into_the_table(self, monkeypatch):
# browserforge draws counts no desktop ships with: over 400 linux draws
# 8.0% were < 4 cores and 4.2% were exactly 2. hardwareConcurrency == 2
# is what Firefox reports under resistFingerprinting, so CreepJS-style
# heuristics label the browser "Firefox resistFingerprinting"; odd counts
# (5, 7, 9, ...) are equally synthetic. They snap DOWN into
# PLAUSIBLE_CORE_COUNTS, with the table floor for anything below it.
from camoufox import cpu_affinity, fingerprints as fp
monkeypatch.setattr(cpu_affinity, "supported", lambda: True)
monkeypatch.setattr(fp, "host_cpu_count", lambda: 16)
for drawn, expected in ((1, 4), (2, 4), (3, 4), (5, 4), (7, 6), (9, 8),
(11, 10), (13, 12), (15, 14), (32, 16)):
c = {"navigator.hardwareConcurrency": drawn}
fp.fix_hardware_concurrency(c)
assert c["navigator.hardwareConcurrency"] == expected, drawn
# never above what the host can be pinned to
monkeypatch.setattr(fp, "host_cpu_count", lambda: 4)
c = {"navigator.hardwareConcurrency": 2}
fp.fix_hardware_concurrency(c)
assert c["navigator.hardwareConcurrency"] == 4
def test_snaps_host_parallelism_when_it_cannot_pin(self, monkeypatch):
# The host count, snapped DOWN into the
# counts real machines ship with; the tails report 24 / 4. Used when the
# draw exceeds the host or the host cannot pin (macOS).
# 24 is in the table (recorded on real Windows/Linux devices); 2 is NOT,
# although it is recorded, because 2 is the resistFingerprinting value.
from camoufox import cpu_affinity, fingerprints as fp
monkeypatch.setattr(cpu_affinity, "supported", lambda: False)
for host, expected in (
(16, 16),
(10, 10),
(24, 24),
(32, 24),
(64, 24),
(7, 6),
(5, 4),
(2, 4),
(9, 8),
):
monkeypatch.setattr(fp, "host_cpu_count", lambda host=host: host)
c = {"navigator.hardwareConcurrency": 2}
fp.fix_hardware_concurrency(c)
assert c["navigator.hardwareConcurrency"] == expected, host
# a draw above the host count cannot be honoured even where pinning works
monkeypatch.setattr(cpu_affinity, "supported", lambda: True)
monkeypatch.setattr(fp, "host_cpu_count", lambda: 8)
c = {"navigator.hardwareConcurrency": 32}
fp.fix_hardware_concurrency(c)
assert c["navigator.hardwareConcurrency"] == 8
def test_no_host_count_leaves_the_draw(self, monkeypatch):
from camoufox import fingerprints as fp
monkeypatch.setattr(fp, "host_cpu_count", lambda: None)
c = {"navigator.hardwareConcurrency": 8}
fp.fix_hardware_concurrency(c)
assert c["navigator.hardwareConcurrency"] == 8
+178
View File
@@ -74,3 +74,181 @@ def test_virtual_display_does_not_mutate_caller_environment(
assert options["env"]["GDK_BACKEND"] == "x11"
assert "WAYLAND_DISPLAY" not in options["env"]
assert options["env"]["MOZ_ENABLE_WAYLAND"] == "0"
class TestFontFallbackAsyncPref:
"""Per-character font fallback must not skip families whose
charmap is not loaded yet.
Gecko's GlobalFontFallback walks the shared font list for a family covering
the character; in a content process with async fallback on it hits the
`!family.IsFullyInitialized()` branch, schedules a cmap load and SKIPS the
family, so the first measurement of a character only one bundled family
provides returns the primary family's .notdef. Linux takes that path for
every fallback (UseCmapsDuringSystemFallback), so the font-hijacker change
that restored this on macOS cannot reach it there.
macOS must NOT get the pref: it uses the CoreText fallback, and forcing the
synchronous scan changed the face picked for U+1E9E in Futura (stock 21.733
-> 27.267), measured on a real Mac mini.
"""
PREF = "gfx.font_rendering.fallback.async"
def _prefs_for(self, ua, isolated):
return utils.launch_options(
config={"navigator.userAgent": ua},
i_know_what_im_doing=True,
)["firefox_user_prefs"]
def test_linux_disables_async_font_fallback(self, isolated_launch_dependencies):
prefs = self._prefs_for("Mozilla/5.0 (X11; Linux x86_64; rv:152.0) Gecko/20100101 Firefox/152.0", None)
assert prefs[self.PREF] is False
def test_macos_keeps_async_font_fallback(self, isolated_launch_dependencies):
prefs = self._prefs_for(
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:152.0) Gecko/20100101 Firefox/152.0", None
)
assert self.PREF not in prefs
def test_windows_keeps_async_font_fallback(self, isolated_launch_dependencies):
prefs = self._prefs_for(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Gecko/20100101 Firefox/152.0", None
)
assert self.PREF not in prefs
def test_caller_pref_wins(self, isolated_launch_dependencies):
prefs = utils.launch_options(
config={"navigator.userAgent": "Mozilla/5.0 (X11; Linux x86_64; rv:152.0) Gecko/20100101 Firefox/152.0"},
firefox_user_prefs={"gfx.font_rendering.fallback.async": True},
i_know_what_im_doing=True,
)["firefox_user_prefs"]
assert prefs[self.PREF] is True
class TestUiLocaleFollowsIntlLocale:
"""The packaged browser locale must follow the spoofed Intl locale.
With locale="fr-FR" and the browser left on en-US, Intl formatting went
French while input.validationMessage and XML parse errors stayed English --
a mix no real Firefox produces. Packages now bake in the language packs;
intl.locale.requested selects one, and must always be set because an empty
value would follow the host OS locale.
"""
PREF = "intl.locale.requested"
UA = "Mozilla/5.0 (X11; Linux x86_64; rv:152.0) Gecko/20100101 Firefox/152.0"
def _prefs(self, **kwargs):
return utils.launch_options(
config={"navigator.userAgent": self.UA, **kwargs.pop("config", {})},
i_know_what_im_doing=True,
**kwargs,
)["firefox_user_prefs"]
def test_default_identity_pins_en_us(self, isolated_launch_dependencies):
assert self._prefs()[self.PREF] == "en-US"
def test_spoofed_locale_selects_matching_ui_locale(self, isolated_launch_dependencies):
# handle_locale adds the likely script; Gecko's filtering negotiation
# treats a packaged "fr" as a range, so fr-Latn-FR still selects fr.
assert self._prefs(locale="fr-FR")[self.PREF] == "fr-Latn-FR"
assert self._prefs(locale="pt-BR")[self.PREF] == "pt-Latn-BR"
def test_first_of_several_locales_wins(self, isolated_launch_dependencies):
assert self._prefs(locale="de-DE, en-US")[self.PREF] == "de-Latn-DE"
def test_geoip_style_config_locale_is_used(self, isolated_launch_dependencies):
config = {"locale:language": "ja", "locale:region": "JP"}
assert self._prefs(config=config)[self.PREF] == "ja-JP"
def test_script_subtag_is_kept(self, isolated_launch_dependencies):
config = {"locale:language": "zh", "locale:script": "Hant", "locale:region": "TW"}
assert self._prefs(config=config)[self.PREF] == "zh-Hant-TW"
def test_caller_pref_wins(self, isolated_launch_dependencies):
prefs = self._prefs(locale="fr-FR", firefox_user_prefs={self.PREF: "de"})
assert prefs[self.PREF] == "de"
class TestPrefsReachStartup:
"""Launcher prefs must be readable by camoufox.cfg at startup.
Playwright's non-persistent launch writes no user.js, so firefox_user_prefs
only arrived via juggler after startup; intl.locale.requested lost the race
against Gecko's pre-created string bundles on Windows.
"""
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Gecko/20100101 Firefox/152.0"
def test_prefs_are_passed_as_env(self, isolated_launch_dependencies):
import orjson
opts = utils.launch_options(config={"navigator.userAgent": self.UA}, locale="fr-FR", i_know_what_im_doing=True)
chunks = sorted((k for k in opts["env"] if k.startswith("CAMOU_PREFS_")), key=lambda k: int(k.rsplit("_", 1)[1]))
assert chunks and chunks[0] == "CAMOU_PREFS_1"
prefs = orjson.loads("".join(opts["env"][k] for k in chunks))
assert prefs == opts["firefox_user_prefs"]
assert prefs["intl.locale.requested"] == "fr-Latn-FR"
def test_large_prefs_are_chunked_in_order(self, monkeypatch):
import orjson
monkeypatch.setattr(utils, "OS_NAME", "win")
prefs = {f"camoufox.test.pref{i}": "x" * 50 for i in range(200)}
env = utils.get_pref_env_vars(prefs)
assert len(env) > 1 and all(len(v) <= 2047 for v in env.values())
joined = "".join(env[f"CAMOU_PREFS_{i}"] for i in range(1, len(env) + 1))
assert orjson.loads(joined) == prefs
def test_no_prefs_no_env(self):
assert utils.get_pref_env_vars({}) == {}
class TestWindowsScrollbarsFollowVersion:
"""Windows 11 draws overlay scrollbars by default (stock 152.0.4 on Win11: 0 px),
Windows 10 classic 17 px ones; the identity's font draw decides the version."""
PREF = "ui.useOverlayScrollbars"
WIN_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Gecko/20100101 Firefox/152.0"
def _prefs(self, fonts, ua=None):
return utils.launch_options(
config={"navigator.userAgent": ua or self.WIN_UA, "fonts": fonts},
i_know_what_im_doing=True,
)["firefox_user_prefs"]
def test_windows_11_fonts_get_overlay(self, isolated_launch_dependencies):
assert self._prefs(["Arial", "Segoe UI Variable Text"])[self.PREF] == 1
def test_windows_10_fonts_get_classic(self, isolated_launch_dependencies):
assert self._prefs(["Arial", "Segoe UI", "Calibri"])[self.PREF] == 0
def test_other_oses_overlay(self, isolated_launch_dependencies):
ua = "Mozilla/5.0 (X11; Linux x86_64; rv:152.0) Gecko/20100101 Firefox/152.0"
assert self._prefs(["DejaVu Sans"], ua=ua)[self.PREF] == 1
def test_marker_set_is_the_font_model(self):
from camoufox.fingerprints import _BASE_VARIANT_FONTS_WINDOWS, WINDOWS_11_MARKER_FONTS
assert WINDOWS_11_MARKER_FONTS == frozenset(_BASE_VARIANT_FONTS_WINDOWS[1])
class TestNativeWindowsVariantFonts:
"""A native Windows identity claims the Win11 font variant iff the host has it."""
def test_native_windows_11_host_claims_variant(self, monkeypatch):
from camoufox import fingerprints as fp
monkeypatch.setattr(fp, "_host_has_variant_fonts", lambda target_os: True)
fonts = fp._generate_random_font_subset("windows", seed=1, native=True)
assert fp.WINDOWS_11_MARKER_FONTS <= set(fonts)
def test_native_windows_10_host_does_not(self, monkeypatch):
from camoufox import fingerprints as fp
monkeypatch.setattr(fp, "_host_has_variant_fonts", lambda target_os: False)
fonts = fp._generate_random_font_subset("windows", seed=1, native=True)
assert not (fp.WINDOWS_11_MARKER_FONTS & set(fonts))
def test_non_windows_hosts_never_report_variant(self):
from camoufox import fingerprints as fp
assert fp._host_has_variant_fonts("linux") is False
assert fp._host_has_variant_fonts("macos") is False
+14 -6
View File
@@ -44,15 +44,23 @@ def test_entries_are_full_objects(target_os):
@pytest.mark.parametrize("target_os", ["macos", "windows", "linux"])
def test_exactly_one_default(target_os):
def test_no_default_voice(target_os):
# Stock Firefox 152 marks no SpeechSynthesisVoice.default on any OS.
voices = _generate_random_voice_subset(target_os, "en-US")
assert sum(1 for v in voices if v["isDefault"]) == 1
assert voices and not any(v["isDefault"] for v in voices)
def test_default_matches_spoofed_locale_prefix():
de = _generate_random_voice_subset("linux", "de-DE")
default = next(v for v in de if v["isDefault"])
assert default["lang"].split("-")[0] == "de"
def test_windows_display_language_pack():
# A German display language gets the German OneCore pack first; en-US is
# the pack every stock en-US box reports (David / Mark / Zira + Desktop).
de = _generate_random_voice_subset("windows", "de-DE", seed=1)
assert de[0]["lang"] == "de-DE"
en = _generate_random_voice_subset("windows", "en-US", seed=1)
assert [v["name"] for v in en[:3]] == [
"Microsoft David - English (United States)",
"Microsoft Mark - English (United States)",
"Microsoft Zira - English (United States)",
]
class TestLinuxSpeechdUris:
@@ -0,0 +1,60 @@
"""Which sampled WebGL extensions reach the page, per OS."""
import sqlite3
import orjson
from camoufox.webgl import sample
from camoufox.webgl.sample import DB_PATH, _load_webgl_data
def _row_with(ext, key="webGl2:supportedExtensions", os="win"):
con = sqlite3.connect(DB_PATH)
try:
for (data,) in con.execute(f"SELECT data FROM webgl_fingerprints WHERE {os} > 0"): # nosec
if ext in (orjson.loads(data).get(key) or []):
return data
finally:
con.close()
raise AssertionError(f"no {os} row carries {ext}")
def test_windows_keeps_ovr_multiview2_on_webgl2():
data = _load_webgl_data(_row_with("OVR_multiview2"), "win")
assert "OVR_multiview2" in data["webGl2:supportedExtensions"]
def test_linux_filters_ovr_multiview2():
data = _load_webgl_data(_row_with("OVR_multiview2", os="lin"), "lin")
assert "OVR_multiview2" not in data["webGl2:supportedExtensions"]
def test_ovr_multiview2_never_on_webgl1_in_corpus():
con = sqlite3.connect(DB_PATH)
try:
for (data,) in con.execute("SELECT data FROM webgl_fingerprints"):
assert "OVR_multiview2" not in (orjson.loads(data).get("webGl:supportedExtensions") or [])
finally:
con.close()
def test_draft_extensions_filtered_on_every_os():
blob = orjson.dumps(
{
"webGl:supportedExtensions": ["ANGLE_instanced_arrays", "WEBGL_multi_draw"],
"webGl2:supportedExtensions": ["EXT_texture_norm16", "WEBGL_clip_cull_distance", "OVR_multiview2"],
}
)
for os in ("win", "mac", "lin"):
data = _load_webgl_data(blob, os)
assert data["webGl:supportedExtensions"] == ["ANGLE_instanced_arrays"]
assert "EXT_texture_norm16" not in data["webGl2:supportedExtensions"]
assert "WEBGL_clip_cull_distance" not in data["webGl2:supportedExtensions"]
def test_sampled_windows_identities_can_carry_it():
hits = sum(
"OVR_multiview2" in (sample.sample_webgl("win", seed=s).get("webGl2:supportedExtensions") or [])
for s in range(200)
)
assert hits > 0
@@ -153,20 +153,19 @@ def test_hardware_is_not_mistaken_for_software(monkeypatch):
assert not is_software_renderer(renderer)
def test_software_first_draw_is_never_resampled(monkeypatch):
"""The strongest reason this sampler must not be a plain reject loop.
Rejecting hardware draws while accepting every software one renormalises
the pool onto llvmpipe / WARP / SwiftShader. On a sub-floor screen that
turned a 1.5% software rate into ~40%, trading a weak incoherence for the
strongest VM/headless tell there is. So the first draw settles the class.
"""
def test_software_first_draw_is_resampled_to_hardware(monkeypatch):
"""A presented llvmpipe / WARP / SwiftShader is the first thing every
consumer-hardware check flags (measured 2026-09-14 with sundial), so a
software first draw is retried until a hardware renderer that fits the
screen comes up, and only kept when the pool offers nothing else."""
draws = iter([{"webGl:renderer": _LLVMPIPE}, {"webGl:renderer": _INTEL}])
monkeypatch.setattr(fingerprints, "sample_webgl", lambda *a, **kw: next(draws))
# 1024x600 would reject a discrete GPU, but llvmpipe is plausible there and
# must be returned as drawn rather than swapped for the Intel part.
assert sample_webgl_for_screen("lin", 1024, 600)["webGl:renderer"] == _LLVMPIPE
assert sample_webgl_for_screen("lin", 1024, 600)["webGl:renderer"] == _INTEL
only_software = iter([{"webGl:renderer": _LLVMPIPE}] * 40)
monkeypatch.setattr(fingerprints, "sample_webgl", lambda *a, **kw: next(only_software))
assert sample_webgl_for_screen("lin", 1920, 1080)["webGl:renderer"] == _LLVMPIPE
def test_software_draws_are_skipped_when_resampling(monkeypatch):