From 3f0f850bf37d15bf4f524ca8b0bfaddf7446a590 Mon Sep 17 00:00:00 2001 From: Jake Writer Date: Wed, 16 Sep 2026 22:09:40 -0600 Subject: [PATCH] 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) --- pythonlib/camoufox/async_api.py | 35 +- pythonlib/camoufox/cpu_affinity.py | 140 +++ pythonlib/camoufox/fingerprints.py | 875 ++++++++++++++++-- pythonlib/camoufox/sync_api.py | 35 +- pythonlib/camoufox/utils.py | 274 +++++- pythonlib/camoufox/voice-manifests.json | 672 ++++++++++++++ pythonlib/camoufox/voice-uris.json | 188 ++++ pythonlib/camoufox/webgl/sample.py | 52 +- pythonlib/tests/test_fingerprint_fixes.py | 131 ++- pythonlib/tests/test_launch_environment.py | 178 ++++ pythonlib/tests/test_voices.py | 20 +- .../tests/test_webgl_extension_filter.py | 60 ++ .../tests/test_webgl_screen_consistency.py | 21 +- tests/patches/config-overrides.py | 21 +- 14 files changed, 2557 insertions(+), 145 deletions(-) create mode 100644 pythonlib/camoufox/cpu_affinity.py create mode 100644 pythonlib/camoufox/voice-manifests.json create mode 100644 pythonlib/camoufox/voice-uris.json create mode 100644 pythonlib/tests/test_webgl_extension_filter.py diff --git a/pythonlib/camoufox/async_api.py b/pythonlib/camoufox/async_api.py index efd7f73..0d219e6 100644 --- a/pythonlib/camoufox/async_api.py +++ b/pythonlib/camoufox/async_api.py @@ -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: diff --git a/pythonlib/camoufox/cpu_affinity.py b/pythonlib/camoufox/cpu_affinity.py new file mode 100644 index 0000000..5ad55c3 --- /dev/null +++ b/pythonlib/camoufox/cpu_affinity.py @@ -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 diff --git a/pythonlib/camoufox/fingerprints.py b/pythonlib/camoufox/fingerprints.py index b283d29..d85365f 100644 --- a/pythonlib/camoufox/fingerprints.py +++ b/pythonlib/camoufox/fingerprints.py @@ -1,9 +1,10 @@ import json import os import re +import unicodedata from dataclasses import asdict, dataclass from pathlib import Path -from random import choice, randint, randrange, random, sample, shuffle +from random import Random, choice, randint, randrange, random, sample, shuffle from typing import Any, Dict, FrozenSet, List, Optional, Tuple from browserforge.fingerprints import ( @@ -27,12 +28,20 @@ PRESETS_V150_FILE = Path(__file__).parent / 'fingerprint-presets-v150.json' PRESETS_V150_MIN_FF = 149 _PRESETS_CACHE: Dict[Path, Dict] = {} -# CreepJS OS marker fonts used for OS detection +# CreepJS OS marker fonts used for OS detection. Twin of MARKER_FONTS in +# scripts/gen-fonts-json.py. Every name must be in fonts.json for its OS +# (scripts/verify-fonts.py checks): a marker the bundle cannot render would be a +# reverse leak. That is why PingFang HK/SC/TC are no longer macOS markers -- the +# bundle carries no PingFang file. On Linux the first three are also +# the families bundle/fontconfig/linux/fonts.conf resolves sans-serif / serif / +# monospace to, so an identity without them would have no face behind any CSS +# generic; Arimo / Cousine / Tinos stay because a real Ubuntu answers "present" +# for them via its metric aliases. _MACOS_MARKER_FONTS = [ - 'Helvetica Neue', 'PingFang HK', 'PingFang SC', 'PingFang TC', + 'Helvetica Neue', ] _LINUX_MARKER_FONTS = [ - 'Arimo', 'Cousine', 'Tinos', 'Twemoji Mozilla', + 'Noto Sans', 'Noto Serif', 'DejaVu Sans Mono', 'Arimo', 'Cousine', 'Tinos', 'Twemoji Mozilla', ] _WINDOWS_MARKER_FONTS = [ 'Segoe UI', 'Tahoma', 'Cambria Math', 'Nirmala UI', @@ -62,30 +71,342 @@ def _load_os_fonts() -> Dict[str, List[str]]: return _OS_FONTS_CACHE -# Essential fonts per OS that must always be included in subsets +# Essential fonts per OS that must always be included in subsets. +# +# These are the OS BASE font sets: every family a real machine of that OS ships +# by default. A real machine has ALL of its OS defaults -- only the additions +# (Office, LibreOffice, Adobe CC, developer and web fonts) vary from box to box -- +# so the base is never subsetted; the random 30-78% draw below applies to the +# additions only (fonts.json minus the base). +# +# Source: the per-OS bases in scripts/data/font-manifests.json (Windows 10 with +# the stock CJK families; macOS Sonoma; Ubuntu and its Mint variant), +# intersected with fonts.json so only names the bundle can +# render are listed (Sonoma's PingFang / Kefa / Hiragino families are in the real +# base but not bundled, so they are absent here). Regenerate together with +# fonts.json: `python3 scripts/gen-fonts-json.py --print-bases`. +# +# Windows: the seven GDI-substitution names (Courier, Helvetica, MS Sans Serif, +# MS Serif, Roman, Small Fonts, Times) and the six Light/Semilight names have no +# file of their own; bundle/fontconfig/windows/fonts.conf rewrites each to its +# bundled target unconditionally, so they MUST stay in this always-reported set +# (an identity that did not report Helvetica would still render it otherwise). _ESSENTIAL_FONTS_MACOS = [ - 'Arial', 'Helvetica', 'Times New Roman', 'Courier New', 'Verdana', - 'Georgia', 'Trebuchet MS', 'Tahoma', 'Helvetica Neue', 'Lucida Grande', - 'Menlo', 'Monaco', 'Geneva', 'PingFang HK', 'PingFang SC', 'PingFang TC', + # PingFang HK/SC/TC ship with every macOS (measured 2026-09-14: present on a + # stock Mac mini in 5/5 runs, never drawn before). + 'PingFang HK', 'PingFang SC', 'PingFang TC', + '.Al Bayan PUA', '.Al Nile PUA', '.Al Tarikh PUA', '.Apple Color Emoji UI', + '.Apple SD Gothic NeoI', '.Aqua Kana', '.Aqua Kana Bold', '.Aqua かな', '.Aqua かな ボールド', + '.Arial Hebrew Desk Interface', '.Baghdad PUA', '.Beirut PUA', '.Damascus PUA', + '.DecoType Naskh PUA', '.Diwan Kufi PUA', '.Farah PUA', '.Geeza Pro Interface', + '.Geeza Pro PUA', '.Hiragino Kaku Gothic Interface', '.Hiragino Sans GB Interface', + '.Keyboard', '.KufiStandardGK PUA', '.LastResort', '.Lucida Grande UI', '.Muna PUA', + '.Nadeem PUA', '.New York', '.Noto Nastaliq Urdu UI', '.SF Arabic', '.SF Arabic Rounded', + '.SF Compact', '.SF Compact Rounded', '.SF NS', '.SF NS Mono', '.SF NS Rounded', + '.Sana PUA', '.Savoye LET CC.', '.ThonburiUI', '.ThonburiUIWatch', 'Academy Engraved LET', + 'Al Bayan', 'Al Nile', 'Al Tarikh', 'American Typewriter', 'American Typewriter Semibold', + 'Andale Mono', 'Apple Braille', 'Apple Chancery', 'Apple Color Emoji', + 'Apple SD Gothic Neo', 'Apple SD Gothic Neo ExtraBold', 'Apple SD 산돌고딕 Neo', + 'Apple Symbols', 'AppleGothic', 'AppleMyungjo', 'Arial', 'Arial Black', 'Arial Hebrew', + 'Arial Hebrew Scholar', 'Arial Narrow', 'Arial Rounded MT Bold', 'Arial Unicode MS', + 'Athelas', 'Avenir', 'Avenir Black', 'Avenir Black Oblique', 'Avenir Book', 'Avenir Heavy', + 'Avenir Light', 'Avenir Medium', 'Avenir Next', 'Avenir Next Condensed', + 'Avenir Next Condensed Demi Bold', 'Avenir Next Condensed Heavy', + 'Avenir Next Condensed Medium', 'Avenir Next Condensed Ultra Light', + 'Avenir Next Demi Bold', 'Avenir Next Heavy', 'Avenir Next Medium', + 'Avenir Next Ultra Light', 'Ayuthaya', 'Baghdad', 'Bangla MN', 'Bangla Sangam MN', + 'Baskerville', 'Beirut', 'Big Caslon', 'Bodoni 72', 'Bodoni 72 Oldstyle', + 'Bodoni 72 Smallcaps', 'Bodoni Ornaments', 'Bradley Hand', 'Brush Script MT', 'Chalkboard', + 'Chalkboard SE', 'Chalkduster', 'Charter', 'Charter Black', 'Cochin', 'Comic Sans MS', + 'Copperplate', 'Corsiva Hebrew', 'Courier', 'Courier New', 'Czcionka systemowa', + 'DIN Alternate', 'DIN Condensed', 'Damascus', 'DecoType Naskh', 'Devanagari MT', + 'Devanagari Sangam MN', 'Didot', 'Diwan Kufi', 'Diwan Thuluth', 'Euphemia UCAS', 'Farah', + 'Farisi', 'Font Sistem', 'Font de sistem', 'Font di sistema', 'Font sustava', + 'Fonte do Sistema', 'Futura', 'Futura Bold', 'GB18030 Bitmap', 'Galvji', 'Geeza Pro', + 'Geneva', 'Georgia', 'Gill Sans', 'Grantha Sangam MN', 'Gujarati MT', 'Gujarati Sangam MN', + 'Gurmukhi MN', 'Gurmukhi MT', 'Gurmukhi Sangam MN', 'Heiti SC', 'Heiti TC', 'Heiti-간체', + 'Heiti-번체', 'Helvetica', 'Helvetica Neue', 'Herculanum', 'Hiragino Kaku Gothic Pro', + 'Hiragino Kaku Gothic Pro W3', 'Hiragino Kaku Gothic Pro W6', 'Hiragino Kaku Gothic ProN', + 'Hiragino Kaku Gothic ProN W3', 'Hiragino Kaku Gothic ProN W6', 'Hiragino Kaku Gothic Std', + 'Hiragino Kaku Gothic Std W8', 'Hiragino Kaku Gothic StdN', 'Hiragino Kaku Gothic StdN W8', + 'Hiragino Maru Gothic Pro', 'Hiragino Maru Gothic Pro W4', 'Hiragino Maru Gothic ProN', + 'Hiragino Maru Gothic ProN W4', 'Hiragino Mincho Pro', 'Hiragino Mincho Pro W3', + 'Hiragino Mincho Pro W6', 'Hiragino Mincho ProN', 'Hiragino Mincho ProN W3', + 'Hiragino Mincho ProN W6', 'Hiragino Sans', 'Hiragino Sans GB', 'Hiragino Sans GB W3', + 'Hiragino Sans GB W6', 'Hiragino Sans W0', 'Hiragino Sans W1', 'Hiragino Sans W2', + 'Hiragino Sans W3', 'Hiragino Sans W4', 'Hiragino Sans W5', 'Hiragino Sans W6', + 'Hiragino Sans W7', 'Hiragino Sans W8', 'Hiragino Sans W9', 'Hoefler Text', + 'Hoefler Text Ornaments', 'ITF Devanagari', 'ITF Devanagari Marathi', 'Impact', 'InaiMathi', + 'InaiMathi Bold', 'Iowan Old Style', 'Iowan Old Style Black', 'Järjestelmäfontti', + 'Kailasa', 'Kannada MN', 'Kannada Sangam MN', 'Khmer MN', 'Khmer Sangam MN', + 'Kohinoor Bangla', 'Kohinoor Devanagari', 'Kohinoor Devanagari Medium', 'Kohinoor Gujarati', + 'Kohinoor Telugu', 'Kokonor', 'Krungthep', 'KufiStandardGK', 'Lao MN', 'Lao Sangam MN', + 'Lucida Grande', 'Luminari', 'Malayalam MN', 'Malayalam Sangam MN', 'Marion', 'Marker Felt', + 'Menlo', 'Microsoft Sans Serif', 'Mishafi', 'Mishafi Gold', 'Monaco', 'Mshtakan', + 'Mukta Mahee', 'MuktaMahee Bold', 'MuktaMahee ExtraBold', 'MuktaMahee ExtraLight', + 'MuktaMahee Light', 'MuktaMahee Medium', 'MuktaMahee Regular', 'MuktaMahee SemiBold', + 'Muna', 'Myanmar MN', 'Myanmar Sangam MN', 'Nadeem', 'New Peninim MT', 'Noteworthy', + 'Noto Nastaliq Urdu', 'Noto Sans Adlam', 'Noto Sans Armenian', 'Noto Sans Armenian Blk', + 'Noto Sans Armenian ExtBd', 'Noto Sans Armenian ExtLt', 'Noto Sans Armenian Light', + 'Noto Sans Armenian Med', 'Noto Sans Armenian SemBd', 'Noto Sans Armenian Thin', + 'Noto Sans Avestan', 'Noto Sans Bamum', 'Noto Sans Bassa Vah', 'Noto Sans Batak', + 'Noto Sans Bhaiksuki', 'Noto Sans Brahmi', 'Noto Sans Buginese', 'Noto Sans Buhid', + 'Noto Sans CanAborig', 'Noto Sans Canadian Aboriginal', + 'Noto Sans Canadian Aboriginal Regular', 'Noto Sans Carian', 'Noto Sans CaucAlban', + 'Noto Sans Caucasian Albanian', 'Noto Sans Chakma', 'Noto Sans Cham', 'Noto Sans Coptic', + 'Noto Sans Cuneiform', 'Noto Sans Cypriot', 'Noto Sans Duployan', 'Noto Sans EgyptHiero', + 'Noto Sans Egyptian Hieroglyphs', 'Noto Sans Elbasan', 'Noto Sans Glagolitic', + 'Noto Sans Gothic', 'Noto Sans Gunjala Gondi', 'Noto Sans Hanifi Rohingya', + 'Noto Sans HanifiRohg', 'Noto Sans Hanunoo', 'Noto Sans Hatran', 'Noto Sans ImpAramaic', + 'Noto Sans Imperial Aramaic', 'Noto Sans InsPahlavi', 'Noto Sans InsParthi', + 'Noto Sans Inscriptional Pahlavi', 'Noto Sans Inscriptional Parthian', 'Noto Sans Javanese', + 'Noto Sans Kaithi', 'Noto Sans Kannada', 'Noto Sans Kannada Black', + 'Noto Sans Kannada ExtraBold', 'Noto Sans Kannada ExtraLight', 'Noto Sans Kannada Light', + 'Noto Sans Kannada Medium', 'Noto Sans Kannada SemiBold', 'Noto Sans Kannada Thin', + 'Noto Sans Kayah Li', 'Noto Sans Kharoshthi', 'Noto Sans Khojki', 'Noto Sans Khudawadi', + 'Noto Sans Lepcha', 'Noto Sans Limbu', 'Noto Sans Linear A', 'Noto Sans Linear B', + 'Noto Sans Lisu', 'Noto Sans Lycian', 'Noto Sans Lydian', 'Noto Sans Mahajani', + 'Noto Sans Mandaic', 'Noto Sans Manichaean', 'Noto Sans Marchen', 'Noto Sans Masaram Gondi', + 'Noto Sans Meetei Mayek', 'Noto Sans Mende Kikakui', 'Noto Sans Meroitic', 'Noto Sans Miao', + 'Noto Sans Modi', 'Noto Sans Mongolian', 'Noto Sans Mro', 'Noto Sans Multani', + 'Noto Sans Myanmar', 'Noto Sans Myanmar Blk', 'Noto Sans Myanmar ExtBd', + 'Noto Sans Myanmar ExtLt', 'Noto Sans Myanmar Light', 'Noto Sans Myanmar Med', + 'Noto Sans Myanmar SemBd', 'Noto Sans Myanmar Thin', 'Noto Sans NKo', 'Noto Sans Nabataean', + 'Noto Sans New Tai Lue', 'Noto Sans Newa', 'Noto Sans Ol Chiki', 'Noto Sans Old Hungarian', + 'Noto Sans Old Italic', 'Noto Sans Old North Arabian', 'Noto Sans Old Permic', + 'Noto Sans Old Persian', 'Noto Sans Old South Arabian', 'Noto Sans Old Turkic', + 'Noto Sans OldHung', 'Noto Sans OldNorArab', 'Noto Sans OldSouArab', 'Noto Sans Oriya', + 'Noto Sans Osage', 'Noto Sans Osmanya', 'Noto Sans Pahawh Hmong', 'Noto Sans Palmyrene', + 'Noto Sans Pau Cin Hau', 'Noto Sans PhagsPa', 'Noto Sans Phoenician', + 'Noto Sans PsaPahlavi', 'Noto Sans Psalter Pahlavi', 'Noto Sans Rejang', + 'Noto Sans Samaritan', 'Noto Sans Saurashtra', 'Noto Sans Sharada', 'Noto Sans Siddham', + 'Noto Sans Sora Sompeng', 'Noto Sans SoraSomp', 'Noto Sans Sundanese', + 'Noto Sans Syloti Nagri', 'Noto Sans Syriac', 'Noto Sans Tagalog', 'Noto Sans Tagbanwa', + 'Noto Sans Tai Le', 'Noto Sans Tai Tham', 'Noto Sans Tai Viet', 'Noto Sans Takri', + 'Noto Sans Thaana', 'Noto Sans Tifinagh', 'Noto Sans Tirhuta', 'Noto Sans Ugaritic', + 'Noto Sans Vai', 'Noto Sans Wancho', 'Noto Sans Warang Citi', 'Noto Sans Yi', + 'Noto Sans Zawgyi', 'Noto Sans Zawgyi Blk', 'Noto Sans Zawgyi ExtBd', + 'Noto Sans Zawgyi ExtLt', 'Noto Sans Zawgyi Light', 'Noto Sans Zawgyi Med', + 'Noto Sans Zawgyi SemBd', 'Noto Sans Zawgyi Thin', 'Noto Serif Ahom', 'Noto Serif Balinese', + 'Noto Serif Hmong Nyiakeng', 'Noto Serif Myanmar', 'Noto Serif Myanmar Blk', + 'Noto Serif Myanmar ExtBd', 'Noto Serif Myanmar ExtLt', 'Noto Serif Myanmar Light', + 'Noto Serif Myanmar Med', 'Noto Serif Myanmar SemBd', 'Noto Serif Myanmar Thin', + 'Noto Serif Yezidi', 'Optima', 'Oriya MN', 'Oriya Sangam MN', 'PT Mono', 'PT Sans', + 'PT Sans Caption', 'PT Sans Narrow', 'PT Serif', 'PT Serif Caption', 'Palatino', 'Papyrus', + 'Party LET', 'Phosphate', 'Phông chữ Hệ thống', 'Plantagenet Cherokee', 'Police système', + 'Raanana', 'Rendszerbetűtípus', 'Rockwell', 'STIX Two Math', 'STIX Two Math Regular', + 'STIX Two Text', 'STIX Two Text Regular', 'STIXGeneral', 'STIXIntegralsD', + 'STIXIntegralsSm', 'STIXIntegralsUp', 'STIXIntegralsUpD', 'STIXIntegralsUpSm', + 'STIXNonUnicode', 'STIXSizeFiveSym', 'STIXSizeFourSym', 'STIXSizeOneSym', + 'STIXSizeThreeSym', 'STIXSizeTwoSym', 'STIXVariants', 'STSong', 'Sana', 'Sathu', + 'Savoye LET', 'Seravek', 'Seravek ExtraLight', 'Seravek Light', 'Seravek Medium', + 'Shree Devanagari 714', 'SignPainter', 'SignPainter-HouseScript', 'Silom', 'Sinhala MN', + 'Sinhala Sangam MN', 'Sistem Fontu', 'Skia', 'Snell Roundhand', 'Songti SC', 'Songti TC', + 'Sukhumvit Set', 'Superclarendon', 'Symbol', 'Systeemlettertype', 'System Font', + 'Systemschrift', 'Systemskrift', 'Systemtypsnitt', 'Systémové písmo', 'Tahoma', 'Tamil MN', + 'Tamil Sangam MN', 'Telugu MN', 'Telugu Sangam MN', 'Thonburi', 'Times', 'Times New Roman', + 'Tipo de letra del sistema', 'Tipo de letra do sistema', 'Tipus de lletra del sistema', + 'Trattatello', 'Trebuchet MS', 'Verdana', 'Waseem', 'Webdings', 'Wingdings', 'Wingdings 2', + 'Wingdings 3', 'Zapf Dingbats', 'Zapfino', 'Γραμματοσειρά συστήματος', 'Системний шрифт', + 'Системный шрифт', 'גופן מערכת', 'البيان', 'التاريخ', 'النيل', 'بغداد', 'بيروت', 'جيزة', + 'خط النظام', 'دمشق', 'ديوان ثلث', 'ديوان كوفي', 'صنعاء', 'فارسي', 'فرح', 'كوفي', 'منى', + 'مِصحفي', 'مِصحفي ذهبي', 'نديم', 'نسخ', 'وسيم', 'कोहिनूर देवनागरी', 'แบบอักษรระบบ', + 'システムフォント', 'ヒラギノ丸ゴ Pro', 'ヒラギノ丸ゴ Pro W4', 'ヒラギノ丸ゴ ProN', 'ヒラギノ丸ゴ ProN W4', 'ヒラギノ明朝 Pro', + 'ヒラギノ明朝 Pro W3', 'ヒラギノ明朝 Pro W6', 'ヒラギノ明朝 ProN', 'ヒラギノ明朝 ProN W3', 'ヒラギノ明朝 ProN W6', + 'ヒラギノ角ゴ Pro', 'ヒラギノ角ゴ Pro W3', 'ヒラギノ角ゴ Pro W6', 'ヒラギノ角ゴ ProN', 'ヒラギノ角ゴ ProN W3', + 'ヒラギノ角ゴ ProN W6', 'ヒラギノ角ゴ Std', 'ヒラギノ角ゴ Std W8', 'ヒラギノ角ゴ StdN', 'ヒラギノ角ゴ StdN W8', + 'ヒラギノ角ゴ 簡体中文', 'ヒラギノ角ゴ 簡体中文 W3', 'ヒラギノ角ゴ 簡体中文 W6', 'ヒラギノ角ゴシック', 'ヒラギノ角ゴシック W0', + 'ヒラギノ角ゴシック W1', 'ヒラギノ角ゴシック W2', 'ヒラギノ角ゴシック W3', 'ヒラギノ角ゴシック W4', 'ヒラギノ角ゴシック W5', + 'ヒラギノ角ゴシック W6', 'ヒラギノ角ゴシック W7', 'ヒラギノ角ゴシック W8', 'ヒラギノ角ゴシック W9', '冬青黑体简体中文', '冬青黑体简体中文 W3', + '冬青黑体简体中文 W6', '冬青黑體簡體中文', '冬青黑體簡體中文 W3', '冬青黑體簡體中文 W6', '宋体-简', '宋体-繁', '宋體-簡', '宋體-繁', + '系統字體', '系统字体', '黑体-简', '黑体-繁', '黑體-簡', '黑體-繁', '黒体-簡', '黒体-繁', '시스템 서체', ] _ESSENTIAL_FONTS_WINDOWS = [ - 'Arial', 'Times New Roman', 'Courier New', 'Verdana', 'Georgia', - 'Trebuchet MS', 'Tahoma', 'Segoe UI', 'Calibri', 'Cambria Math', - 'Nirmala UI', 'Consolas', + 'Arial', 'Arial Black', 'Bahnschrift', 'Calibri', 'Calibri Light', 'Cambria', + 'Cambria Math', 'Candara', 'Candara Light', 'Comic Sans MS', 'Consolas', 'Constantia', + 'Corbel', 'Corbel Light', 'Courier', 'Courier New', 'Ebrima', 'Franklin Gothic Medium', + 'Gabriola', 'Gadugi', 'Georgia', 'Helvetica', 'Impact', 'Ink Free', 'Javanese Text', + 'Leelawadee UI', 'Leelawadee UI Semilight', 'Lucida Console', 'Lucida Sans Unicode', + 'MS Gothic', 'MS PGothic', 'MS Sans Serif', 'MS Serif', 'MS UI Gothic', 'MV Boli', + 'Malgun Gothic', 'Malgun Gothic Semilight', 'Marlett', 'Microsoft Himalaya', + 'Microsoft JhengHei', 'Microsoft JhengHei Light', 'Microsoft JhengHei UI', + 'Microsoft JhengHei UI Light', 'Microsoft New Tai Lue', 'Microsoft PhagsPa', + 'Microsoft Sans Serif', 'Microsoft Tai Le', 'Microsoft YaHei', 'Microsoft YaHei Light', + 'Microsoft YaHei UI', 'Microsoft YaHei UI Light', 'Microsoft Yi Baiti', 'MingLiU-ExtB', + 'MingLiU_HKSCS-ExtB', 'MingLiU_MSCS-ExtB', 'Mongolian Baiti', 'Myanmar Text', 'NSimSun', + 'Nirmala Text', 'Nirmala Text Semilight', 'Nirmala UI', 'Nirmala UI Semilight', + 'PMingLiU-ExtB', 'Palatino Linotype', 'Roman', 'Segoe MDL2 Assets', 'Segoe Print', + 'Segoe Script', 'Segoe UI', 'Segoe UI Black', 'Segoe UI Emoji', 'Segoe UI Historic', + 'Segoe UI Light', 'Segoe UI Semibold', 'Segoe UI Semilight', 'Segoe UI Symbol', 'SimSun', + 'SimSun-ExtB', 'Sitka Banner', 'Sitka Display', 'Sitka Heading', 'Sitka Small', + 'Sitka Subheading', 'Sitka Text', 'Small Fonts', 'Sylfaen', 'Symbol', 'Tahoma', 'Times', + 'Times New Roman', 'Trebuchet MS', 'Twemoji Mozilla', 'Verdana', 'Webdings', 'Wingdings', + 'Yu Gothic', 'Yu Gothic Light', 'Yu Gothic Medium', 'Yu Gothic UI', 'Yu Gothic UI Light', + 'Yu Gothic UI Semibold', 'Yu Gothic UI Semilight', '宋体', '微軟正黑體', '微軟正黑體 Light', '微软雅黑', + '微软雅黑 Light', '新宋体', '新細明體-ExtB', '游ゴシック', '游ゴシック Light', '游ゴシック Medium', '細明體-ExtB', + '細明體_HKSCS-ExtB', '細明體_MSCS-ExtB', '맑은 고딕', '맑은 고딕 Semilight', 'MS ゴシック', 'MS Pゴシック', ] _ESSENTIAL_FONTS_LINUX = [ - 'Arimo', 'Cousine', 'Tinos', 'Twemoji Mozilla', - 'Noto Sans Devanagari', 'Noto Sans JP', 'Noto Sans KR', - 'Noto Sans SC', 'Noto Sans TC', + # metric-compatible alias names a stock Linux fontconfig always resolves + 'Arial', 'Arial Narrow', 'Helvetica', 'Helvetica Narrow', 'Times', 'Times New Roman', + 'Courier', 'Courier New', 'Calibri', 'Cambria', 'Palatino', 'Palatino Linotype', + 'Bookman Old Style', 'Century Schoolbook', 'Avant Garde', 'Zapf Chancery', 'Symbol', + 'C059', 'D050000L', 'DejaVu Sans', 'DejaVu Sans Mono', 'DejaVu Serif', + 'Droid Sans Fallback', 'FreeMono', 'FreeSans', 'FreeSerif', 'Liberation Mono', + 'Liberation Sans', 'Liberation Serif', 'Nimbus Mono PS', 'Nimbus Roman', 'Nimbus Sans', + 'Nimbus Sans Narrow', 'Noto Color Emoji', 'Noto Kufi Arabic', 'Noto Looped Lao', + 'Noto Looped Thai', 'Noto Mono', 'Noto Music', 'Noto Naskh Arabic', 'Noto Nastaliq Urdu', + 'Noto Rashi Hebrew', 'Noto Sans', 'Noto Sans Adlam', 'Noto Sans Adlam Unjoined', + 'Noto Sans Anatolian Hieroglyphs', 'Noto Sans Arabic', 'Noto Sans Armenian', + 'Noto Sans Avestan', 'Noto Sans Balinese', 'Noto Sans Bamum', 'Noto Sans Bassa Vah', + 'Noto Sans Batak', 'Noto Sans Bengali', 'Noto Sans Bhaiksuki', 'Noto Sans Brahmi', + 'Noto Sans Buginese', 'Noto Sans Buhid', 'Noto Sans CJK HK', 'Noto Sans CJK JP', + 'Noto Sans CJK KR', 'Noto Sans CJK SC', 'Noto Sans CJK TC', 'Noto Sans Canadian Aboriginal', + 'Noto Sans Carian', 'Noto Sans Caucasian Albanian', 'Noto Sans Chakma', 'Noto Sans Cham', + 'Noto Sans Cherokee', 'Noto Sans Coptic', 'Noto Sans Cuneiform', 'Noto Sans Cypriot', + 'Noto Sans Deseret', 'Noto Sans Devanagari', 'Noto Sans Display', 'Noto Sans Duployan', + 'Noto Sans Egyptian Hieroglyphs', 'Noto Sans Elbasan', 'Noto Sans Elymaic', + 'Noto Sans Ethiopic', 'Noto Sans Georgian', 'Noto Sans Glagolitic', 'Noto Sans Gothic', + 'Noto Sans Grantha', 'Noto Sans Gujarati', 'Noto Sans Gunjala Gondi', 'Noto Sans Gurmukhi', + 'Noto Sans Hanifi Rohingya', 'Noto Sans Hanunoo', 'Noto Sans Hatran', 'Noto Sans Hebrew', + 'Noto Sans Imperial Aramaic', 'Noto Sans Indic Siyaq Numbers', + 'Noto Sans Inscriptional Pahlavi', 'Noto Sans Inscriptional Parthian', 'Noto Sans Javanese', + 'Noto Sans Kaithi', 'Noto Sans Kannada', 'Noto Sans Kayah Li', 'Noto Sans Kharoshthi', + 'Noto Sans Khmer', 'Noto Sans Khojki', 'Noto Sans Khudawadi', 'Noto Sans Lao', + 'Noto Sans Lepcha', 'Noto Sans Limbu', 'Noto Sans Linear A', 'Noto Sans Linear B', + 'Noto Sans Lisu', 'Noto Sans Lycian', 'Noto Sans Lydian', 'Noto Sans Mahajani', + 'Noto Sans Malayalam', 'Noto Sans Mandaic', 'Noto Sans Manichaean', 'Noto Sans Marchen', + 'Noto Sans Masaram Gondi', 'Noto Sans Math', 'Noto Sans Mayan Numerals', + 'Noto Sans Medefaidrin', 'Noto Sans Meetei Mayek', 'Noto Sans Mende Kikakui', + 'Noto Sans Meroitic', 'Noto Sans Miao', 'Noto Sans Modi', 'Noto Sans Mongolian', + 'Noto Sans Mono', 'Noto Sans Mono CJK HK', 'Noto Sans Mono CJK JP', 'Noto Sans Mono CJK KR', + 'Noto Sans Mono CJK SC', 'Noto Sans Mono CJK TC', 'Noto Sans Mro', 'Noto Sans Multani', + 'Noto Sans Myanmar', 'Noto Sans NKo', 'Noto Sans Nabataean', 'Noto Sans New Tai Lue', + 'Noto Sans Newa', 'Noto Sans Nushu', 'Noto Sans Ogham', 'Noto Sans Ol Chiki', + 'Noto Sans Old Hungarian', 'Noto Sans Old Italic', 'Noto Sans Old North Arabian', + 'Noto Sans Old Permic', 'Noto Sans Old Persian', 'Noto Sans Old Sogdian', + 'Noto Sans Old South Arabian', 'Noto Sans Old Turkic', 'Noto Sans Oriya', 'Noto Sans Osage', + 'Noto Sans Osmanya', 'Noto Sans Pahawh Hmong', 'Noto Sans Palmyrene', + 'Noto Sans Pau Cin Hau', 'Noto Sans PhagsPa', 'Noto Sans Phoenician', + 'Noto Sans Psalter Pahlavi', 'Noto Sans Rejang', 'Noto Sans Runic', 'Noto Sans Samaritan', + 'Noto Sans Saurashtra', 'Noto Sans Sharada', 'Noto Sans Shavian', 'Noto Sans Siddham', + 'Noto Sans SignWriting', 'Noto Sans Sinhala', 'Noto Sans Sogdian', 'Noto Sans Sora Sompeng', + 'Noto Sans Soyombo', 'Noto Sans Sundanese', 'Noto Sans Syloti Nagri', 'Noto Sans Symbols', + 'Noto Sans Symbols2', 'Noto Sans Syriac', 'Noto Sans Tagalog', 'Noto Sans Tagbanwa', + 'Noto Sans Tai Le', 'Noto Sans Tai Tham', 'Noto Sans Tai Viet', 'Noto Sans Takri', + 'Noto Sans Tamil', 'Noto Sans Tamil Supplement', 'Noto Sans Telugu', 'Noto Sans Thaana', + 'Noto Sans Thai', 'Noto Sans Tifinagh', 'Noto Sans Tifinagh APT', + 'Noto Sans Tifinagh Adrar', 'Noto Sans Tifinagh Agraw Imazighen', + 'Noto Sans Tifinagh Ahaggar', 'Noto Sans Tifinagh Air', 'Noto Sans Tifinagh Azawagh', + 'Noto Sans Tifinagh Ghat', 'Noto Sans Tifinagh Hawad', 'Noto Sans Tifinagh Rhissa Ixa', + 'Noto Sans Tifinagh SIL', 'Noto Sans Tifinagh Tawellemmet', 'Noto Sans Tirhuta', + 'Noto Sans Ugaritic', 'Noto Sans Vai', 'Noto Sans Wancho', 'Noto Sans Warang Citi', + 'Noto Sans Yi', 'Noto Sans Zanabazar Square', 'Noto Serif', 'Noto Serif Ahom', + 'Noto Serif Armenian', 'Noto Serif Balinese', 'Noto Serif Bengali', 'Noto Serif CJK HK', + 'Noto Serif CJK JP', 'Noto Serif CJK KR', 'Noto Serif CJK SC', 'Noto Serif CJK TC', + 'Noto Serif Devanagari', 'Noto Serif Display', 'Noto Serif Dogra', 'Noto Serif Ethiopic', + 'Noto Serif Georgian', 'Noto Serif Grantha', 'Noto Serif Gujarati', 'Noto Serif Gurmukhi', + 'Noto Serif Hebrew', 'Noto Serif Hmong Nyiakeng', 'Noto Serif Kannada', 'Noto Serif Khmer', + 'Noto Serif Khojki', 'Noto Serif Lao', 'Noto Serif Malayalam', 'Noto Serif Myanmar', + 'Noto Serif Sinhala', 'Noto Serif Tamil', 'Noto Serif Tamil Slanted', 'Noto Serif Tangut', + 'Noto Serif Telugu', 'Noto Serif Thai', 'Noto Serif Tibetan', 'Noto Serif Yezidi', + 'Noto Traditional Nushu', 'OpenSymbol', 'P052', 'Standard Symbols PS', 'URW Bookman', + 'URW Gothic', 'Ubuntu', 'Ubuntu Mono', 'Ubuntu Sans', 'Ubuntu Sans Mono', 'Z003', ] +# OS-version variants of the base, drawn ALL-OR-NOTHING on top of the essential +# core with the real-world share of that version (the manifest's base weights). A +# Windows 11 machine (65%) has every one of the Win11 additions and a Windows 10 +# machine none of them; Ubuntu (65%) ships Liberation Sans Narrow, Mint (35%) +# does not. macOS has a single bundled base (Sonoma). Format: (probability, fonts). +_BASE_VARIANT_FONTS_MACOS = (0.0, []) +_BASE_VARIANT_FONTS_WINDOWS = (0.65, [ + 'Cascadia Code', 'Cascadia Mono', 'Sans Serif Collection', 'Segoe Fluent Icons', + 'Segoe UI Variable Display', 'Segoe UI Variable Small', 'Segoe UI Variable Text', +]) +_BASE_VARIANT_FONTS_LINUX = (0.65, ['Liberation Sans Narrow']) -def _generate_random_font_subset(target_os: str) -> List[str]: +# Fonts only a Windows 11 base has: a Windows identity whose font list contains +# them presents Windows 11, and the rest of the identity (overlay scrollbars, +# utils.launch_options) must agree. +WINDOWS_11_MARKER_FONTS = frozenset(_BASE_VARIANT_FONTS_WINDOWS[1]) + + + +def identity_seed(config: Dict[str, Any]) -> int: + """A stable seed for the per-identity draws (fonts, voices). + + Two launches that present the same identity (same UA, platform, screen, + cores, GPU) must present the same font and voice lists: a page that keeps + cookies across launches and sees the font set or the voice list change + under an otherwise identical device reads it as a spoofed browser + (daijro/camoufox#442, #765, #378). Deriving the seed from the identity + itself makes the draw a pure function of the fingerprint, so `from_options` + replays and persistent contexts are stable without any new state. + """ + import zlib + parts = [ + str(config.get('navigator.userAgent', '')), + str(config.get('navigator.platform', '')), + str(config.get('screen.width', '')), + str(config.get('screen.height', '')), + str(config.get('navigator.hardwareConcurrency', '')), + # not the GPU: it is sampled after the font draw in launch_options + ] + return zlib.crc32('|'.join(parts).encode('utf-8')) & 0xFFFFFFFF + + +def _rng(seed: Optional[int]) -> Random: + """Seeded generator for a draw, or the module-level one when unseeded.""" + return Random(seed) if seed is not None else Random() + + +_FONT_GROUPS_CACHE: Optional[Dict[str, List[Dict[str, Any]]]] = None + + +def _load_font_groups() -> Dict[str, List[Dict[str, Any]]]: + """Co-shipped font groups per OS (font-groups.json, derived from the + bundle-kind additions of scripts/data/font-manifests.json): each entry is + {"id": ..., "fonts": [...]} and is drawn all-or-nothing.""" + global _FONT_GROUPS_CACHE + if _FONT_GROUPS_CACHE is None: + path = os.path.join(os.path.dirname(__file__), 'font-groups.json') + try: + with open(path, 'rb') as f: + _FONT_GROUPS_CACHE = json.loads(f.read()) + except (OSError, ValueError): + _FONT_GROUPS_CACHE = {} + return _FONT_GROUPS_CACHE + + +def _host_has_variant_fonts(target_os: str) -> bool: + """Whether the host itself ships the OS-version font variant (native identities only).""" + if target_os != 'windows': + return False + import os + fonts_dir = os.path.join(os.environ.get('WINDIR', r'C:\Windows'), 'Fonts') + # SegUIVar.ttf is Segoe UI Variable, present on every Windows 11 install and on no Windows 10. + return os.path.exists(os.path.join(fonts_dir, 'SegUIVar.ttf')) + + +def _generate_random_font_subset( + target_os: str, seed: Optional[int] = None, native: bool = False +) -> List[str]: """ Generate a random subset of fonts for the given OS. - Picks a random percentage between 30-78% of non-essential fonts, - always includes essential + marker fonts. + Always includes the essential fonts (the OS base, i.e. every family a real + machine of that OS ships), draws the OS-version variant of the base + all-or-nothing, then picks a random percentage between 30-78% of the + remaining fonts.json families (the additions), and finally ensures the + marker fonts are present. + + `native`: the identity is the host's own OS (macOS / Windows), where the + browser uses the real system fonts and not the bundle. Only the OS base + is claimed then: an "addition" the host does not have would be listed but + fall back when measured, which a page can see (measured 2026-09-14 on a + stock Mac mini: Fira Code / Lato claimed, rendered as Menlo). """ + rng = _rng(seed) os_fonts_data = _load_os_fonts() os_key = {'macos': 'mac', 'windows': 'win', 'linux': 'lin'}.get(target_os, 'mac') full_list = os_fonts_data.get(os_key, os_fonts_data.get('mac', [])) @@ -93,27 +414,67 @@ def _generate_random_font_subset(target_os: str) -> List[str]: if target_os == 'windows': essential = set(_ESSENTIAL_FONTS_WINDOWS) markers = _WINDOWS_MARKER_FONTS + variant_prob, variant_fonts = _BASE_VARIANT_FONTS_WINDOWS elif target_os == 'linux': essential = set(_ESSENTIAL_FONTS_LINUX) markers = _LINUX_MARKER_FONTS + variant_prob, variant_fonts = _BASE_VARIANT_FONTS_LINUX else: essential = set(_ESSENTIAL_FONTS_MACOS) markers = _MACOS_MARKER_FONTS + variant_prob, variant_fonts = _BASE_VARIANT_FONTS_MACOS + variant = set(variant_fonts) - # Split into essential and non-essential + # The base is always present in full. An essential family the bundle does + # not carry (PingFang is Apple's, never redistributed) is still claimed: + # on the host OS it is the real system font. result = [f for f in full_list if f in essential] - non_essential = [f for f in full_list if f not in essential] + if native: + result.extend(sorted(f for f in essential if f not in set(full_list))) + # The OS-version variant is real system fonts too: claim it exactly when + # the host has it. A Windows 11 host presented as Windows 10 hides Segoe UI + # Variable etc. and, with the matching classic scrollbars, differs from the + # stock Firefox on the same machine (Windows 11 test host, 2026-09-16: + # 0 px overlay). + if variant and _host_has_variant_fonts(target_os): + result.extend(f for f in variant_fonts if f not in result) + return result + + # The OS-version variant of the base is all-or-nothing. + if variant and rng.random() < variant_prob: + result.extend(f for f in full_list if f in variant) + + # Everything else in fonts.json is an addition; draw a random subset of it. + # Families that install as ONE download (Office, LibreOffice, Adobe CC, + # Cascadia Code+Mono, Meslo LG S/M/L, ...) are drawn as a single unit so a + # draw never produces a partial group -- a partial group is a synthetic + # artifact no real machine shows (sundial "co-shipped families not split"). + # The 30-78% rule is applied over these units, not over bare family names. + non_essential = [f for f in full_list if f not in essential and f not in variant] + grouped: Dict[str, List[str]] = {} + for group in _load_font_groups().get(os_key, []): + members = [f for f in group['fonts'] if f in non_essential] + for f in members: + grouped[f] = members + units: List[List[str]] = [] + seen = set() + for f in non_essential: + if f in seen: + continue + members = grouped.get(f, [f]) + seen.update(members) + units.append(members) # Random percentage between 30-78% - pct = 30 + int(random() * 49) - count = round((pct / 100) * len(non_essential)) + pct = 30 + int(rng.random() * 49) + count = round((pct / 100) * len(units)) - # Randomly select non-essential fonts - if count < len(non_essential): - selected = sample(non_essential, count) + # Randomly select non-essential units + if count < len(units): + chosen = rng.sample(units, count) else: - selected = non_essential - result.extend(selected) + chosen = units + result.extend(f for unit in chosen for f in unit) # Ensure marker fonts are present _ensure_marker_fonts(result, markers) @@ -200,12 +561,111 @@ def _voice_uri(os_key: str, name: str, lang: str) -> str: else: escaped.append(''.join(f'%{b:02X}' for b in ch.encode('utf-8'))) return f"{_VOICE_URI_PREFIX['lin']}{''.join(escaped)}?{lang}" + if os_key == 'win': + # SapiService.cpp: "urn:moz-tts:sapi:" + name + "?" + lang, verbatim + # (measured 2026-09-14 on a stock Windows 11: spaces and parentheses + # unescaped, e.g. "...sapi:Microsoft David - English (United States)?en-US"). + return f"{_VOICE_URI_PREFIX['win']}{name}?{lang}" + if os_key == 'mac': + # OSXSpeechSynthesizerService: "urn:moz-tts:osx:" + AVSpeechSynthesisVoice + # identifier. Catalogue captured from a stock macOS (voice-uris.json); + # voices outside it follow Apple's identifier families. + uri = _load_voice_uris().get('mac', {}).get(f'{name}|{lang}') + if uri: + return uri + ascii_name = re.sub(r'[^A-Za-z0-9]', '', unicodedata.normalize('NFKD', name)) + if name in _MAC_NOVELTY_VOICES: + # e.g. com.apple.speech.synthesis.voice.Albert / .Fred / .Victoria + # (capitalised as the voice name; multi-word names are joined) + return f"{_VOICE_URI_PREFIX['mac']}com.apple.speech.synthesis.voice.{ascii_name}" + if name in _MAC_ELOQUENCE_VOICES: + return f"{_VOICE_URI_PREFIX['mac']}com.apple.eloquence.{lang}.{ascii_name}" + return f"{_VOICE_URI_PREFIX['mac']}com.apple.voice.compact.{lang}.{ascii_name}" return f"{_VOICE_URI_PREFIX.get(os_key, '')}{_voice_uri_slug(name)}" +_MAC_NOVELTY_VOICES = frozenset( + {'Albert', 'Bad News', 'Bahh', 'Bells', 'Boing', 'Bubbles', 'Cellos', 'Wobble', 'Good News', 'Jester', + 'Organ', 'Superstar', 'Trinoids', 'Whisper', 'Zarvox', 'Fred', 'Junior', 'Kathy', 'Ralph', + 'Bruce', 'Vicki', 'Victoria', 'Agnes', 'Princess', 'Hysterical', 'Pipe Organ', 'Deranged'} +) +_MAC_ELOQUENCE_VOICES = frozenset({'Eddy', 'Flo', 'Grandma', 'Grandpa', 'Reed', 'Rocko', 'Sandy', 'Shelley'}) +_VOICE_URIS_CACHE: Optional[Dict[str, Dict[str, str]]] = None + + +def _load_voice_uris() -> Dict[str, Dict[str, str]]: + """Real voiceURI per "Name|lang" as a stock browser reports it (voice-uris.json).""" + global _VOICE_URIS_CACHE + if _VOICE_URIS_CACHE is None: + path = os.path.join(os.path.dirname(__file__), 'voice-uris.json') + try: + with open(path, 'rb') as f: + _VOICE_URIS_CACHE = json.loads(f.read()) + except OSError: + _VOICE_URIS_CACHE = {} + return _VOICE_URIS_CACHE + + +def _load_voice_manifests() -> Dict[str, Any]: + """The per-OS installed-voice model (voice-manifests.json): a base the OS + always ships, Windows language packs keyed by display locale, and additions + drawn as atomic bundles / a-la-carte voices / whole language packs.""" + global _VOICE_MANIFESTS_CACHE + if _VOICE_MANIFESTS_CACHE is None: + path = os.path.join(os.path.dirname(__file__), 'voice-manifests.json') + with open(path, 'rb') as f: + _VOICE_MANIFESTS_CACHE = json.loads(f.read()) + return _VOICE_MANIFESTS_CACHE + + +_VOICE_MANIFESTS_CACHE: Optional[Dict[str, Any]] = None + + +def _split_voice_entry(entry: str) -> Tuple[str, str, str]: + name, lang, vtype = entry.rsplit(':', 2) + return name, lang, vtype + + +def _weighted_pick(rng: Random, items: List[Dict[str, Any]], wkey: str = 'w') -> Dict[str, Any]: + total = sum(float(i.get(wkey, 0)) for i in items) + r = rng.random() * total + for i in items: + r -= float(i.get(wkey, 0)) + if r <= 0: + return i + return items[-1] + + +def _weighted_sample(rng: Random, items: List[Any], k: int, weight) -> List[Any]: + pool = list(items) + out: List[Any] = [] + while pool and len(out) < k: + total = sum(weight(x) for x in pool) + r = rng.random() * total + for x in pool: + r -= weight(x) + if r <= 0: + out.append(x) + pool.remove(x) + break + else: + out.append(pool.pop()) + return out + + +def _resolve_display_pack(packs: Dict[str, Any], fallback: str, locale: Optional[str]) -> str: + if locale: + if locale in packs: + return locale + lang = locale.split('-')[0].lower() + for key in packs: + if key.split('-')[0].lower() == lang: + return key + return fallback if fallback in packs else next(iter(packs)) + + def _generate_random_voice_subset( - target_os: str, locale: Optional[str] = None -) -> List[Dict[str, Any]]: + target_os: str, locale: Optional[str] = None, seed: Optional[int] = None) -> List[Dict[str, Any]]: """Generate the speech voice list for the given OS as MaskConfig objects. Returns a list of {lang, name, voiceUri, isDefault, isLocalService} dicts, @@ -214,35 +674,77 @@ def _generate_random_voice_subset( Without this override, Firefox registers the HOST machine's speech-dispatcher / SAPI / NSSpeech voices, leaking the OS the wrapper - actually runs on. We therefore emit a list for EVERY target OS: - macOS: essential voices + a random 40-80% of the rest. - Windows: full SAPI set (subsetting a fixed list reads as suspicious). - Linux: full espeak-ng base-language set (~131 voices) as enumerated - by speech-dispatcher — the fixed list a Linux Firefox exposes. + actually runs on. The list follows a measured model of what a stock + machine exposes (voice-manifests.json): + Windows: the display language's OneCore pack (en-US: David/Mark/Zira), + its legacy "Desktop" tokens, and occasionally extra packs; + macOS: the compact + Eloquence base (~184 voices) plus rare downloads; + Linux: speech-dispatcher's fixed espeak-ng list (131 voices). + Seeded by the identity so the same identity always reports the same list. """ - os_voices_data = _load_os_voices() + rng = _rng(seed) os_key = {'macos': 'mac', 'windows': 'win', 'linux': 'lin'}.get(target_os, 'mac') - full_list = os_voices_data.get(os_key, []) + manifest = _load_voice_manifests().get(os_key) or _load_voice_manifests()['mac'] - if not full_list: + out: List[str] = [] + seen = set() + + def add(entries): + for e in entries or []: + if e not in seen: + seen.add(e) + out.append(e) + + legacy: List[str] = [] + packs = manifest.get('langPacks') or {} + + def take_pack(pack): + add(pack.get('oneCore')) + if pack.get('desktop') and rng.random() < float(pack.get('desktopProb') or 0): + for e in pack['desktop']: + if e not in legacy: + legacy.append(e) + + add(manifest.get('base')) + chosen = set() + if packs: + key = _resolve_display_pack(packs, manifest.get('fallbackLocale') or 'en-US', locale) + chosen.add(key) + take_pack(packs[key]) + + for addition in manifest.get('additions', []): + if addition.get('deferred'): + continue + req = addition.get('requiresLocale') + if req and not (locale or '').lower().startswith(req.lower()): + continue + if rng.random() >= float(addition.get('prob') or 0): + continue + kind = addition.get('kind') + if kind == 'bundle': + add(addition.get('voices')) + elif kind == 'alacarte': + sizes = addition.get('sizes') or [{'n': 1, 'w': 1}] + k = int(_weighted_pick(rng, sizes)['n']) + for e in _weighted_sample(rng, addition.get('voices') or [], k, lambda x: 1.0): + if e not in seen: + seen.add(e) + # a downloaded voice sits in its alphabetical place + idx = next((i for i, v in enumerate(out) if v.lower() > e.lower()), len(out)) + out.insert(idx, e) + elif kind == 'groups' and packs: + eligible = [g for g in addition.get('groups') or [] if g in packs and g not in chosen] + if not eligible: + continue + k = int(_weighted_pick(rng, addition['sizes'])['n']) if addition.get('sizes') else len(eligible) + for g in _weighted_sample(rng, eligible, k, lambda g: float(packs[g].get('weight') or 0.01)): + chosen.add(g) + take_pack(packs[g]) + + selected = [_split_voice_entry(e) for e in out + legacy] + if not selected: return [] - if os_key in ('win', 'lin'): - # Fixed lists across installs (SAPI / espeak-ng) — ship the whole set. - selected = list(full_list) - else: - # macOS: essential voices + random 40-80% of the rest. - essential = set(_ESSENTIAL_VOICES_MACOS) - result = [v for v in full_list if v[0] in essential] - non_essential = [v for v in full_list if v[0] not in essential] - pct = 40 + int(random() * 41) # 40-80% - count = round((pct / 100) * len(non_essential)) - if count < len(non_essential): - result.extend(sample(non_essential, count)) - else: - result.extend(non_essential) - selected = result - voices: List[Dict[str, Any]] = [ { 'name': name, @@ -254,6 +756,15 @@ def _generate_random_voice_subset( for (name, lang, vtype) in selected ] + # No voice carries default=true: stock Firefox 152 marks none on Windows + # (SAPI), macOS or Linux (measured 2026-09-14 on all three), so a spoofed + # default would be the odd one out. + return voices + if os_key == 'mac': + pref = next((i for i, v in enumerate(voices) if v['name'] in ('Samantha', 'Alex') and (not locale or v['lang'].lower() == locale.lower())), -1) + if pref >= 0: + voices[pref]['isDefault'] = True + return voices # Mark a default voice matching the spoofed locale prefix so it lines up # with Intl.DateTimeFormat().resolvedOptions().locale (CreepJS flags a # voiceLangMismatch otherwise). @@ -316,6 +827,92 @@ def _normalize_preset_voices( return result +def host_cpu_count() -> Optional[int]: + """Logical CPUs this process may actually run on (cgroup/affinity aware).""" + try: + return len(os.sched_getaffinity(0)) or None # type: ignore[attr-defined] + except (AttributeError, OSError): + return os.cpu_count() + + +# Core counts real desktop machines ship with, taken from the RECORDED +# fingerprint corpus rather than invented: fingerprint-presets.json and +# -v150.json between them contain 2, 4, 6, 8, 10, 12, 14, 16, 20 and 24. +# +# 24 was missing from this table and is restored (2026-09-15): it is a real +# recorded value on Windows (2/75) and Linux (2/18), and excluding it snapped +# genuine 24-core machines down to 20 for no reason. +# +# 2 is recorded too -- and is common, 6/30 macOS presets (20%) -- but is +# deliberately EXCLUDED (user, 2026-09-15): 2 is what Firefox reports under +# resistFingerprinting, and the goal is to look like a DEFAULT Firefox, which +# RFP is not. So a draw of 2 snaps up to the table floor of 4. +# +# A host outside this table would hand its own oddity to the fingerprint: a +# 64-thread build box reports 24, anything under 4 threads reports 4. Odd +# counts (5, 7, 9, 11, 13, 15) never appear in the corpus -- they are +# browserforge Bayesian synthesis -- so they keep getting snapped down. +PLAUSIBLE_CORE_COUNTS = (4, 6, 8, 10, 12, 14, 16, 20, 24) + + +def fix_hardware_concurrency(config: Dict[str, Any]) -> None: + """navigator.hardwareConcurrency = the host's parallelism, snapped DOWN + into PLAUSIBLE_CORE_COUNTS. + + A drawn value that differs from the machine the browser runs on is + measurable from a page: timing N parallel workers reveals how many cores + are really usable, and both "more usable than reported" and "fewer usable + than reported" are flagged by WebCPU-style checks (sundial "CPU: reported + vs measured cores"; daijro/camoufox#442 for the drift across launches). + So the drawn value is discarded, not clamped: min(drawn, host) still lets + a draw of 2 be measured as 16. + + Stock Firefox 152 reports the true count in a normal window (capped by + dom.maxHardwareConcurrency = 128; measured 2026-09-14: 16-thread Linux and + Windows hosts -> 16, a 10-core Mac mini -> 10). Its 8/4 tiering + (RFPTarget NavigatorHWConcurrencyTiered: >= 8 -> 8, else 4) applies only + with fingerprinting protection on, i.e. private windows and ETP strict, + and resistFingerprinting hardcodes 4 (8 on macOS) -- neither is a normal + window's behaviour, so nothing is rounded here beyond the table snap. + The two tails (host > 20 or < 4) are the residual where reported and + measurable can disagree; closing them needs CPU affinity pinning, not a + launcher value. A caller that sets navigator.hardwareConcurrency + themselves is left alone (see the _user_set_navigator guard). + """ + n = host_cpu_count() + if not n: + return + # The fingerprint's own value survives when the browser can be pinned to + # that many cores (cpu_affinity: Linux, Windows): reported and measurable + # then agree by construction, and the identity keeps its diversity. A draw + # the host cannot honour (more cores than it has), or a host that cannot + # pin (macOS), falls back to the snapped host count. + from .cpu_affinity import supported as _can_pin + + cap = int(n) + host_allowed = [c for c in PLAUSIBLE_CORE_COUNTS if c <= cap] + host_value = host_allowed[-1] if host_allowed else PLAUSIBLE_CORE_COUNTS[0] + + drawn = config.get('navigator.hardwareConcurrency') + if _can_pin() and isinstance(drawn, int) and drawn >= 1: + # The fingerprint's value is kept for diversity, but it still has to be + # a count a real desktop ships with. Accepting any 1..host let + # browserforge's low/odd draws through: over 400 linux draws, 8.0% were + # < 4 cores and 4.2% were exactly 2 -- and hardwareConcurrency == 2 is + # the value Firefox reports under resistFingerprinting, so CreepJS-style + # heuristics label the browser "Firefox resistFingerprinting" (this is + # what intermittently failed sundial's "Privacy mode verdict"). Odd + # counts (5, 7, 9, 11, 13, 15) survived the same way. Snap the draw DOWN + # into the table instead, capped by the host so pinning can honour it. + target = min(drawn, cap) + allowed = [c for c in PLAUSIBLE_CORE_COUNTS if c <= target] + config['navigator.hardwareConcurrency'] = ( + allowed[-1] if allowed else min(PLAUSIBLE_CORE_COUNTS[0], cap) + ) + return + config['navigator.hardwareConcurrency'] = host_value + + def fix_navigator_arch(config: Dict[str, Any], target_os: str) -> None: """Force navigator.platform AND navigator.oscpu to match the UA's arch. @@ -453,21 +1050,139 @@ def clamp_window_position(config: Dict[str, Any]) -> None: def set_media_devices_defaults(config: Dict[str, Any]) -> None: - """Spoof navigator.mediaDevices.enumerateDevices() so headless contexts - expose a plausible device list. + """Give the identity a plausible set of media devices. - A real desktop browser without explicit mic permission reports one - audioinput + one videoinput; an empty list is a headless tell. The patched - MediaDevices::FilterExposedDevices reads mediaDevices:{enabled,micros, - webcams,speakers}. Default to one of each input kind unless the caller - already set any mediaDevices: key. + The patched media backend (media-device-spoofing.patch) enumerates and + captures exactly the devices described by mediaDevices:{enabled, micros, + webcams, speakers} and the aligned mediaDevices:{microphone,webcam, + speaker}{Labels,Groups} lists, and Firefox's own pre-/post-grant exposure + rules apply to them. Nothing is drawn when the caller already set any + mediaDevices: key. """ if any(k.startswith('mediaDevices:') for k in config): return - config['mediaDevices:enabled'] = True - config['mediaDevices:micros'] = 1 - config['mediaDevices:webcams'] = 1 - config['mediaDevices:speakers'] = 0 + # Before any getUserMedia grant Firefox exposes at most ONE device per + # input kind and no audiooutput; after a grant it lists every device with + # the OS's own labels ("Microphone Array (Realtek(R) Audio)", "MacBook Pro + # Microphone", "Built-in Audio Analog Stereo"...). Draw a whole machine's + # worth from the common desktop population for the claimed OS + # (media-devices.json), seeded by the identity so the same identity + # always reports the same devices. The browser applies the stock + # pre-/post-grant exposure rules to this list. + plat = str(config.get('navigator.platform', '')) + if plat.startswith('Win'): + os_key = 'win' + elif plat.startswith('Mac'): + os_key = 'mac' + else: + os_key = 'lin' + config.update(draw_media_devices(os_key, identity_seed(config))) + + +_MEDIA_DEVICES_CACHE: Optional[Dict[str, Any]] = None + + +def _load_media_devices() -> Dict[str, Any]: + """Per-OS catalogue of common sound cards / headsets / display audio / + cameras with their post-grant labels (media-devices.json).""" + global _MEDIA_DEVICES_CACHE + if _MEDIA_DEVICES_CACHE is None: + path = os.path.join(os.path.dirname(__file__), 'media-devices.json') + with open(path, 'rb') as f: + _MEDIA_DEVICES_CACHE = json.loads(f.read()) + return _MEDIA_DEVICES_CACHE + + +def _weighted_choice(rng: Random, items: List[Dict[str, Any]]) -> Dict[str, Any]: + total = float(sum(item.get('w', 1) for item in items)) + r = rng.random() * total + for item in items: + r -= item.get('w', 1) + if r < 0: + return item + return items[-1] + + +# Share of machines with no microphone at all (a desktop tower with only a +# line-out) and with a built-in camera, per OS. macOS is modelled by the +# machine line itself (Mac mini / Mac Studio have neither). +_MEDIA_P_NO_MIC = {'win': 0.08, 'mac': 0.0, 'lin': 0.20} +_MEDIA_P_BUILTIN_CAM = {'win': 0.78, 'mac': 0.0, 'lin': 0.45} + + +def draw_media_devices(os_key: str, seed: Optional[int]) -> Dict[str, Any]: + """Draw one machine's media devices for `os_key` ('win'|'mac'|'lin'). + + Returns the mediaDevices:* config keys: counts plus aligned label and + group lists. Devices of one piece of hardware (a sound card's microphone + and speakers, a webcam and its microphone) share a group, as their + groupId does on a real machine; Linux additionally lists the PulseAudio + "Monitor of ..." source of every output as a microphone, as Firefox does. + """ + rng = _rng(seed) + cat = _load_media_devices().get(os_key) or _load_media_devices()['win'] + mics: List[Tuple[str, str]] = [] + outs: List[Tuple[str, str]] = [] + cams: List[Tuple[str, str]] = [] + counter = [0] + + def group() -> str: + counter[0] += 1 + return f'hw-{counter[0]}' + + def add(item: Dict[str, Any], grp: str) -> None: + for m in item.get('mics', []): + mics.append((m, grp)) + for o in item.get('outs', []): + outs.append((o, grp)) + if item.get('cam'): + cams.append((item['cam'], group())) + + # 1. the machine's own sound card (+ built-in camera on macOS models) + card = _weighted_choice(rng, cat['cards']) + no_mic = rng.random() < _MEDIA_P_NO_MIC.get(os_key, 0.0) + card_grp = group() + if no_mic: + add({**card, 'mics': []}, card_grp) + else: + add(card, card_grp) + # 2. a built-in laptop camera (Windows/Linux); rare on a mic-less tower + p_cam = _MEDIA_P_BUILTIN_CAM.get(os_key, 0.0) + if rng.random() < (p_cam * 0.3 if no_mic else p_cam): + builtin = [c for c in cat['cameras'] if not c.get('mic')] + if builtin: + cams.append((_weighted_choice(rng, builtin)['cam'], group())) + # 3. a headset / USB microphone + if rng.random() < cat.get('p_headset', 0.0): + add(_weighted_choice(rng, cat['headsets']), group()) + # 4. display audio (HDMI/DP) -- occasionally a display with mic + camera + if rng.random() < cat.get('p_display', 0.0): + add(_weighted_choice(rng, cat['displays']), group()) + # 5. an external webcam, usually with its own microphone + if rng.random() < cat.get('p_extra_camera', 0.0): + external = [c for c in cat['cameras'] if c.get('mic')] or cat['cameras'] + cam = _weighted_choice(rng, external) + grp = group() + cams.append((cam['cam'], grp)) + if cam.get('mic'): + mics.append((cam['mic'], grp)) + # 6. PulseAudio exposes a monitor source per output as a capture device + if cat.get('monitor_sources'): + for label, grp in list(outs): + mics.append((f'Monitor of {label}', grp)) + + return { + 'mediaDevices:enabled': True, + 'mediaDevices:micros': len(mics), + 'mediaDevices:webcams': len(cams), + 'mediaDevices:speakers': len(outs), + 'mediaDevices:microphoneLabels': [m for m, _ in mics], + 'mediaDevices:microphoneGroups': [g for _, g in mics], + 'mediaDevices:webcamLabels': [c for c, _ in cams], + 'mediaDevices:webcamGroups': [g for _, g in cams], + 'mediaDevices:speakerLabels': [o for o, _ in outs], + 'mediaDevices:speakerGroups': [g for _, g in outs], + } # -- WebGL <-> screen coherence (#729) --------------------------------------- @@ -629,6 +1344,7 @@ def sample_webgl_for_screen( width: Optional[int] = None, height: Optional[int] = None, attempts: int = 32, + seed: Optional[int] = None, ) -> Dict[str, str]: """Sample a WebGL profile that is coherent with the screen already chosen. @@ -649,20 +1365,27 @@ def sample_webgl_for_screen( Falls back to that first draw when the pool holds nothing coherent, so an unusual screen degrades to today's behaviour rather than raising. """ - first = sample_webgl(target_os) + # A software rasteriser (llvmpipe / SwiftShader / WARP) as the presented + # GPU is what every consumer-hardware check flags first ("no consumer + # machine reports llvmpipe" -- sundial, measured 2026-09-14), so the draw + # never settles on one: keep drawing until a hardware renderer that fits + # the screen comes up, and only fall back to the first draw if the pool + # holds nothing better. + first = sample_webgl(target_os, seed=seed) renderer = first.get('webGl:renderer') - if is_software_renderer(renderer) or gpu_screen_is_plausible(renderer, width, height): + if not is_software_renderer(renderer) and gpu_screen_is_plausible(renderer, width, height): return first - for _ in range(attempts - 1): - candidate = sample_webgl(target_os) + fallback = None if is_software_renderer(renderer) else first + for attempt in range(attempts - 1): + candidate = sample_webgl(target_os, seed=None if seed is None else seed + 1 + attempt) renderer = candidate.get('webGl:renderer') - # Skip rather than accept: the class was settled by the first draw. if is_software_renderer(renderer): continue if gpu_screen_is_plausible(renderer, width, height): return candidate - return first + fallback = fallback or candidate + return fallback or first def _select_presets_file(ff_version: Optional[Any] = None) -> Path: @@ -835,7 +1558,9 @@ def from_preset(preset: Dict, ff_version: Optional[str] = None) -> Dict[str, Any config['webGl:renderer'] = webgl['unmaskedRenderer'] # Generate unique random seeds per launch (1 to 2^32-1, excluding 0 which is a no-op in C++) - config['fonts:spacing_seed'] = randint(1, 4_294_967_295) # nosec + # fonts:spacing_seed stays 0 (off): glyph-advance perturbation produces text + # widths no real machine emits (see launch_options in utils.py). + config['fonts:spacing_seed'] = 0 config['audio:seed'] = randint(1, 4_294_967_295) # nosec config['canvas:seed'] = randint(1, 4_294_967_295) # nosec @@ -853,7 +1578,7 @@ def from_preset(preset: Dict, ff_version: Optional[str] = None) -> Dict[str, Any else: target_os = 'macos' try: - config['fonts'] = _generate_random_font_subset(target_os) + config['fonts'] = _generate_random_font_subset(target_os, seed=identity_seed(config)) except Exception: # Fallback to preset fonts if font generation fails if preset.get('fonts'): @@ -866,7 +1591,7 @@ def from_preset(preset: Dict, ff_version: Optional[str] = None) -> Dict[str, Any config['fonts'] = fonts # Generate a unique random voice subset from the OS voice list try: - config['voices'] = _generate_random_voice_subset(target_os) + config['voices'] = _generate_random_voice_subset(target_os, seed=identity_seed(config)) except Exception: if preset.get('speechVoices'): config['voices'] = _normalize_preset_voices( @@ -1003,7 +1728,7 @@ def generate_context_fingerprint( config = from_browserforge(fp, ff_version) # Add seeds (BrowserForge doesn't generate these) - config.setdefault('fonts:spacing_seed', randint(1, 4_294_967_295)) # nosec + config.setdefault('fonts:spacing_seed', 0) # perturbation off; see utils.launch_options config.setdefault('audio:seed', randint(1, 4_294_967_295)) # nosec config.setdefault('canvas:seed', randint(1, 4_294_967_295)) # nosec @@ -1018,14 +1743,14 @@ def generate_context_fingerprint( # Add fonts (BrowserForge doesn't generate these) if 'fonts' not in config: try: - config['fonts'] = _generate_random_font_subset(os_name) + config['fonts'] = _generate_random_font_subset(os_name, seed=identity_seed(config)) except Exception: pass # Add voices (BrowserForge doesn't generate these) if 'voices' not in config: try: - config['voices'] = _generate_random_voice_subset(os_name) + config['voices'] = _generate_random_voice_subset(os_name, seed=identity_seed(config)) except Exception: pass diff --git a/pythonlib/camoufox/sync_api.py b/pythonlib/camoufox/sync_api.py index fde3a88..04e09a9 100644 --- a/pythonlib/camoufox/sync_api.py +++ b/pythonlib/camoufox/sync_api.py @@ -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: diff --git a/pythonlib/camoufox/utils.py b/pythonlib/camoufox/utils.py index 07c28e0..bc61ce1 100644 --- a/pythonlib/camoufox/utils.py +++ b/pythonlib/camoufox/utils.py @@ -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// so one + # artifact can claim any OS. fontconfig scans 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 diff --git a/pythonlib/camoufox/voice-manifests.json b/pythonlib/camoufox/voice-manifests.json new file mode 100644 index 0000000..9d6d42f --- /dev/null +++ b/pythonlib/camoufox/voice-manifests.json @@ -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." + } + ] + } +} \ No newline at end of file diff --git a/pythonlib/camoufox/voice-uris.json b/pythonlib/camoufox/voice-uris.json new file mode 100644 index 0000000..732a70b --- /dev/null +++ b/pythonlib/camoufox/voice-uris.json @@ -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" + } +} \ No newline at end of file diff --git a/pythonlib/camoufox/webgl/sample.py b/pythonlib/camoufox/webgl/sample.py index f963d05..fba9b72 100644 --- a/pythonlib/camoufox/webgl/sample.py +++ b/pythonlib/camoufox/webgl/sample.py @@ -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]]]: diff --git a/pythonlib/tests/test_fingerprint_fixes.py b/pythonlib/tests/test_fingerprint_fixes.py index 715382f..a8a694c 100644 --- a/pythonlib/tests/test_fingerprint_fixes.py +++ b/pythonlib/tests/test_fingerprint_fixes.py @@ -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 diff --git a/pythonlib/tests/test_launch_environment.py b/pythonlib/tests/test_launch_environment.py index b462528..03db73d 100644 --- a/pythonlib/tests/test_launch_environment.py +++ b/pythonlib/tests/test_launch_environment.py @@ -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 diff --git a/pythonlib/tests/test_voices.py b/pythonlib/tests/test_voices.py index b55d1de..a5b7e52 100644 --- a/pythonlib/tests/test_voices.py +++ b/pythonlib/tests/test_voices.py @@ -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: diff --git a/pythonlib/tests/test_webgl_extension_filter.py b/pythonlib/tests/test_webgl_extension_filter.py new file mode 100644 index 0000000..057748c --- /dev/null +++ b/pythonlib/tests/test_webgl_extension_filter.py @@ -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 diff --git a/pythonlib/tests/test_webgl_screen_consistency.py b/pythonlib/tests/test_webgl_screen_consistency.py index 5cf8bd3..bb4a611 100644 --- a/pythonlib/tests/test_webgl_screen_consistency.py +++ b/pythonlib/tests/test_webgl_screen_consistency.py @@ -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): diff --git a/tests/patches/config-overrides.py b/tests/patches/config-overrides.py index 46df3a1..2c51b62 100644 --- a/tests/patches/config-overrides.py +++ b/tests/patches/config-overrides.py @@ -82,16 +82,25 @@ async def test(): else: raise RuntimeError("Could not find a valid preset") from last_error - # --- Test 2: without config_overrides, seed is non-zero --- - print("\n=== Test 2: default (no overrides) gets non-zero seed ===") + # --- Test 2: without config_overrides, the perturbation is OFF (seed 0) --- + # Glyph-advance perturbation moves every measured text width off the value + # the same font gives on a real machine, so the default is 0; an explicit + # non-zero seed must still be honoured (opt-in). + print("\n=== Test 2: default (no overrides) seed is 0, explicit seed honoured ===") preset2 = get_random_preset(os="macos") fp2 = generate_context_fingerprint(preset=preset2) seed2 = fp2["config"]["fonts:spacing_seed"] - if seed2 != 0: - print(f" Default seed is {seed2} (non-zero): PASS") + if seed2 == 0: + print(" Default seed is 0 (perturbation off): PASS") else: - failures.append("Default seed is 0 — should be random non-zero") - print(f" Default seed is 0: FAIL") + failures.append(f"Default seed is {seed2} — should be 0 (perturbation off by default)") + print(f" Default seed is {seed2}: FAIL") + fp2b = generate_context_fingerprint(preset=preset2, config_overrides={"fonts:spacing_seed": 12345}) + if fp2b["config"]["fonts:spacing_seed"] == 12345: + print(" Explicit seed 12345 honoured: PASS") + else: + failures.append("Explicit fonts:spacing_seed override was not honoured") + print(" Explicit seed override: FAIL") # --- Test 3: init_script contains setFontSpacingSeed(0) when overridden --- print("\n=== Test 3: init_script emits setFontSpacingSeed(0) ===")