mirror of
https://github.com/daijro/camoufox.git
synced 2026-10-07 08:00:33 +00:00
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:
co-authored by
Claude Opus 5
parent
8c7eec556f
commit
3f0f850bf3
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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]]]:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user