mirror of
https://github.com/daijro/camoufox.git
synced 2026-09-09 00:00:39 +00:00
fix: page-recycle hang under spoofed window dims, and the unmerged halves of #637-#647
Fixes the new_page() hang from #666, and restores the pythonlib/ + settings/ halves of #637-#647 that were dropped when those PRs were consolidated into #666 (that PR only carried patches/ + additions/, so these never actually landed). ## new_page() hangs when window.outer* is spoofed (#666) The outer-size hijack in browser-init.patch pinned the chrome documentElement to the spoofed size. That caps .browserStack, which caps the content viewport, so the content window can never reach the size Juggler asks for in updateViewportSize() -- and awaitViewportDimensions awaits exact equality with no timeout, so it deadlocks rather than erroring. The second new_page() hung forever and took the context with it. The pin was never load-bearing: GetOuterWidth/GetOuterHeight already consult MaskConfig unconditionally (fingerprint-injection.patch), so window.outerWidth is spoofed in C++ regardless of the real chrome window size. Resizing is enough. Measured on the official v152.0.4-beta.26 build (headless): config before after none pass pass inner pass pass outer HANG pass both HANG pass (iw:360 ih:740 ow:360 oh:800 -- exact) This corrects the diagnosis in #666, which blamed the inner+outer combination and the `!(outerWidth || outerHeight)` guard. outer* ALONE is sufficient to hang, and dropping inner* does not help, so that guard is not the culprit. Also fixed driver-side: Playwright's implicit 1280x720 viewport is what asks for the impossible size, so the driver now defaults to no_viewport when the config spoofs any window dimension. That fixes the hang on already-released builds without a rebuild. An explicit viewport=/no_viewport= from the caller wins. ## WebRTC ICE prefs (#538) #666 merged the C++ half of the WebRTC fix but not the prefs, so the shipped build still has no_host=true and none of the proxy_only prefs. proxy_only_if_behind_proxy is the pref that actually stops the real-IP leak: it prevents a UDP STUN request routing around a TCP proxy. no_host=false keeps the stock two-candidate shape, which obfuscate_host_addresses makes leak-free. ## Also restored from the consolidation - fix(proxy): dom.security.https_first rewrote http:// before the launch-arg proxy filter saw it, breaking CONNECT-only proxies (#638). - fix(stealth): speech-voice spoofing + stop leaking host voices (#646). - fix(stealth): clamp inner <= outer <= avail <= screen; BrowserForge can emit impossible geometries that leak as tells (#647). Refs: https://github.com/daijro/camoufox/pull/666 Refs: https://github.com/daijro/camoufox/issues/538
This commit is contained in:
@@ -16,7 +16,12 @@ from typing_extensions import Literal
|
||||
from camoufox.virtdisplay import VirtualDisplay
|
||||
|
||||
from .fingerprints import generate_context_fingerprint
|
||||
from .utils import async_attach_vd, launch_options
|
||||
from .utils import (
|
||||
async_attach_vd,
|
||||
attach_no_viewport_default,
|
||||
launch_options,
|
||||
spoofs_window_dimensions,
|
||||
)
|
||||
|
||||
|
||||
class AsyncCamoufox(PlaywrightContextManager):
|
||||
@@ -105,13 +110,21 @@ async def AsyncNewBrowser(
|
||||
partial(launch_options, headless=headless, debug=debug, **kwargs),
|
||||
)
|
||||
|
||||
# Playwright's default viewport deadlocks Juggler when the window is spoofed
|
||||
# to a different size (daijro/camoufox#666), so default to no_viewport.
|
||||
no_viewport_default = spoofs_window_dimensions(from_options)
|
||||
|
||||
# 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)
|
||||
|
||||
|
||||
|
||||
@@ -121,12 +121,17 @@ def _generate_random_font_subset(target_os: str) -> List[str]:
|
||||
return result
|
||||
|
||||
|
||||
# OS voice lists loaded from voices.json
|
||||
_OS_VOICES_CACHE: Optional[Dict[str, List[str]]] = None
|
||||
# OS voice lists loaded from voices.json, parsed into "Name:lang:type" tuples.
|
||||
_OS_VOICES_CACHE: Optional[Dict[str, List[Tuple[str, str, str]]]] = None
|
||||
|
||||
|
||||
def _load_os_voices() -> Dict[str, List[str]]:
|
||||
"""Load OS voice lists from voices.json, extracting voice names."""
|
||||
def _load_os_voices() -> Dict[str, List[Tuple[str, str, str]]]:
|
||||
"""Load OS voice lists from voices.json as (name, lang, type) tuples.
|
||||
|
||||
Each entry is "Name:lang:type" (type is "local" or "remote"). Voice names
|
||||
may contain parens/commas but not colons, so a last-two-colons split is
|
||||
safe.
|
||||
"""
|
||||
global _OS_VOICES_CACHE
|
||||
if _OS_VOICES_CACHE is not None:
|
||||
return _OS_VOICES_CACHE
|
||||
@@ -134,10 +139,23 @@ def _load_os_voices() -> Dict[str, List[str]]:
|
||||
with open(voices_path, 'rb') as f:
|
||||
import orjson
|
||||
raw = orjson.loads(f.read())
|
||||
# Extract voice names from "Name:locale:type" format
|
||||
_OS_VOICES_CACHE = {}
|
||||
for os_key, entries in raw.items():
|
||||
_OS_VOICES_CACHE[os_key] = [e.split(':')[0] for e in entries]
|
||||
parsed: List[Tuple[str, str, str]] = []
|
||||
for entry in entries:
|
||||
last = entry.rfind(':')
|
||||
if last < 0:
|
||||
continue
|
||||
vtype = entry[last + 1:]
|
||||
before = entry[:last]
|
||||
langsep = before.rfind(':')
|
||||
if langsep < 0:
|
||||
continue
|
||||
lang = before[langsep + 1:]
|
||||
name = before[:langsep]
|
||||
if name and lang:
|
||||
parsed.append((name, lang, vtype))
|
||||
_OS_VOICES_CACHE[os_key] = parsed
|
||||
return _OS_VOICES_CACHE
|
||||
|
||||
|
||||
@@ -151,13 +169,56 @@ _ESSENTIAL_VOICES_WINDOWS = [
|
||||
'Microsoft Mark - English (United States)',
|
||||
]
|
||||
|
||||
# Real Firefox speechSynthesis URI prefixes per backend.
|
||||
# macOS NSSpeechSynthesizer -> "urn:moz-tts:osx:<dotted-slug>"
|
||||
# Windows SAPI -> "urn:moz-tts:sapi:<dotted-slug>"
|
||||
# Linux speech-dispatcher -> "urn:moz-tts:speechd:<escaped-name>?<lang>"
|
||||
_VOICE_URI_PREFIX = {
|
||||
'mac': 'urn:moz-tts:osx:',
|
||||
'win': 'urn:moz-tts:sapi:',
|
||||
'lin': 'urn:moz-tts:speechd:',
|
||||
}
|
||||
|
||||
def _generate_random_voice_subset(target_os: str) -> List[str]:
|
||||
"""
|
||||
Generate a random subset of speech voices for the given OS.
|
||||
macOS: random 40-80% of non-essential + essential always included.
|
||||
Windows: all voices (too few to subset meaningfully).
|
||||
Linux: empty list (no native speech voices).
|
||||
|
||||
def _voice_uri_slug(name: str) -> str:
|
||||
"""Stable dotted slug for mac/win URIs (shape-plausible, not catalog-exact)."""
|
||||
return re.sub(r'^\.|\.$', '', re.sub(r'[^a-z0-9]+', '.', name.lower()))
|
||||
|
||||
|
||||
def _voice_uri(os_key: str, name: str, lang: str) -> str:
|
||||
"""Build a voiceUri matching what real Firefox emits for the OS backend."""
|
||||
if os_key == 'lin':
|
||||
# Firefox's SpeechDispatcherService.cpp builds:
|
||||
# "urn:moz-tts:speechd:" + NS_EscapeURL(name, OnlyNonASCII|Spaces) + "?" + lang
|
||||
# i.e. spaces -> %20 and non-ASCII bytes -> %XX, ASCII punctuation intact.
|
||||
escaped = []
|
||||
for ch in name:
|
||||
if ch == ' ':
|
||||
escaped.append('%20')
|
||||
elif ord(ch) <= 0x7F:
|
||||
escaped.append(ch)
|
||||
else:
|
||||
escaped.append(''.join(f'%{b:02X}' for b in ch.encode('utf-8')))
|
||||
return f"{_VOICE_URI_PREFIX['lin']}{''.join(escaped)}?{lang}"
|
||||
return f"{_VOICE_URI_PREFIX.get(os_key, '')}{_voice_uri_slug(name)}"
|
||||
|
||||
|
||||
def _generate_random_voice_subset(
|
||||
target_os: str, locale: Optional[str] = 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,
|
||||
the shape MaskConfig::MVoices() requires (it silently drops any entry
|
||||
missing a field, so raw name strings would register nothing).
|
||||
|
||||
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.
|
||||
"""
|
||||
os_voices_data = _load_os_voices()
|
||||
os_key = {'macos': 'mac', 'windows': 'win', 'linux': 'lin'}.get(target_os, 'mac')
|
||||
@@ -166,27 +227,203 @@ def _generate_random_voice_subset(target_os: str) -> List[str]:
|
||||
if not full_list:
|
||||
return []
|
||||
|
||||
# Windows has too few voices to subset — return all
|
||||
if target_os == 'windows':
|
||||
return list(full_list)
|
||||
|
||||
# macOS: random 40-80% subset
|
||||
essential = set(_ESSENTIAL_VOICES_MACOS)
|
||||
result = [v for v in full_list if v in essential]
|
||||
non_essential = [v for v in full_list if v not in essential]
|
||||
|
||||
pct = 40 + int(random() * 41) # 40-80%
|
||||
count = round((pct / 100) * len(non_essential))
|
||||
|
||||
if count < len(non_essential):
|
||||
selected = sample(non_essential, count)
|
||||
if os_key in ('win', 'lin'):
|
||||
# Fixed lists across installs (SAPI / espeak-ng) — ship the whole set.
|
||||
selected = list(full_list)
|
||||
else:
|
||||
selected = non_essential
|
||||
result.extend(selected)
|
||||
# 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,
|
||||
'lang': lang,
|
||||
'voiceUri': _voice_uri(os_key, name, lang),
|
||||
'isDefault': False,
|
||||
'isLocalService': vtype == 'local',
|
||||
}
|
||||
for (name, lang, vtype) in selected
|
||||
]
|
||||
|
||||
# Mark a default voice matching the spoofed locale prefix so it lines up
|
||||
# with Intl.DateTimeFormat().resolvedOptions().locale (CreepJS flags a
|
||||
# voiceLangMismatch otherwise).
|
||||
if voices:
|
||||
prefix = locale.split('-')[0].lower() if locale else 'en'
|
||||
idx = next(
|
||||
(i for i, v in enumerate(voices) if locale and v['lang'].lower() == locale.lower()),
|
||||
-1,
|
||||
)
|
||||
if idx < 0:
|
||||
idx = next(
|
||||
(i for i, v in enumerate(voices) if v['lang'].split('-')[0].lower() == prefix),
|
||||
-1,
|
||||
)
|
||||
if idx < 0:
|
||||
idx = 0
|
||||
voices[idx]['isDefault'] = True
|
||||
|
||||
return voices
|
||||
|
||||
|
||||
def _normalize_preset_voices(
|
||||
voices: Any, target_os: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Coerce a preset's `speechVoices` into MaskConfig voice objects.
|
||||
|
||||
Presets historically store voices as "Name:lang:type" strings, which the
|
||||
C++ MaskConfig::MVoices() silently drops (it needs full objects). Convert
|
||||
them; pass through entries that are already objects.
|
||||
"""
|
||||
os_key = {'macos': 'mac', 'windows': 'win', 'linux': 'lin'}.get(target_os, 'mac')
|
||||
result: List[Dict[str, Any]] = []
|
||||
for entry in voices:
|
||||
if isinstance(entry, dict):
|
||||
result.append(entry)
|
||||
continue
|
||||
last = entry.rfind(':')
|
||||
if last < 0:
|
||||
continue
|
||||
vtype = entry[last + 1:]
|
||||
before = entry[:last]
|
||||
langsep = before.rfind(':')
|
||||
if langsep < 0:
|
||||
continue
|
||||
lang = before[langsep + 1:]
|
||||
name = before[:langsep]
|
||||
if not name or not lang:
|
||||
continue
|
||||
result.append(
|
||||
{
|
||||
'name': name,
|
||||
'lang': lang,
|
||||
'voiceUri': _voice_uri(os_key, name, lang),
|
||||
'isDefault': False,
|
||||
'isLocalService': vtype == 'local',
|
||||
}
|
||||
)
|
||||
if result and not any(v['isDefault'] for v in result):
|
||||
result[0]['isDefault'] = True
|
||||
return result
|
||||
|
||||
|
||||
def fix_navigator_arch(config: Dict[str, Any], target_os: str) -> None:
|
||||
"""Force navigator.platform AND navigator.oscpu to match the UA's arch.
|
||||
|
||||
~8% of Linux Firefox fingerprints in the BrowserForge pool report
|
||||
"Linux armv81" for platform/oscpu while the UA says "Linux x86_64". That
|
||||
arch mismatch is itself a CreepJS lie signal (CreepJS cross-checks oscpu,
|
||||
platform, and the UA arch). Mac/Windows pools are consistent and need no
|
||||
correction.
|
||||
"""
|
||||
if target_os != 'lin':
|
||||
return
|
||||
ua = config.get('navigator.userAgent')
|
||||
if not ua:
|
||||
return
|
||||
target = ''
|
||||
if 'Linux x86_64' in ua:
|
||||
target = 'Linux x86_64'
|
||||
elif 'Linux i686' in ua:
|
||||
target = 'Linux i686'
|
||||
if not target:
|
||||
return
|
||||
if config.get('navigator.platform') != target:
|
||||
config['navigator.platform'] = target
|
||||
if config.get('navigator.oscpu') != target:
|
||||
config['navigator.oscpu'] = target
|
||||
|
||||
|
||||
def fix_screen_no_taskbar(config: Dict[str, Any], target_os: str) -> None:
|
||||
"""Ensure screen.availHeight < screen.height so CreepJS's noTaskbar flag
|
||||
(screen.height == availHeight and screen.width == availWidth) doesn't flip.
|
||||
|
||||
Every desktop OS keeps some chrome visible (Mac menu bar ~25px, Win taskbar
|
||||
~40px, Linux panel ~27px); the BrowserForge pool occasionally ships
|
||||
fingerprints with identical screen/avail values which leak as a headless
|
||||
tell. Also clamp window.outerHeight (and innerHeight) to the new avail so
|
||||
the window isn't taller than the available area.
|
||||
"""
|
||||
sw = config.get('screen.width')
|
||||
sh = config.get('screen.height')
|
||||
aw = config.get('screen.availWidth')
|
||||
ah = config.get('screen.availHeight')
|
||||
if not (sw and sh and aw == sw and ah == sh):
|
||||
return
|
||||
taskbar = 40 if target_os == 'win' else 25 if target_os == 'mac' else 27
|
||||
new_avail = sh - taskbar
|
||||
config['screen.availHeight'] = new_avail
|
||||
oh = config.get('window.outerHeight')
|
||||
if oh and oh > new_avail:
|
||||
ih = config.get('window.innerHeight')
|
||||
chrome = oh - ih if ih else 0
|
||||
config['window.outerHeight'] = new_avail
|
||||
if ih:
|
||||
config['window.innerHeight'] = new_avail - chrome
|
||||
|
||||
|
||||
def clamp_window_dimensions(config: Dict[str, Any]) -> None:
|
||||
"""Enforce inner <= outer <= avail <= screen on BOTH axes.
|
||||
|
||||
The browser faithfully reports whatever we inject, so a BrowserForge
|
||||
fingerprint that ships e.g. outerWidth > screen.width or innerWidth >
|
||||
outerWidth leaks as an impossible geometry. Shrink each level down to its
|
||||
container, preserving the chrome delta between outer and inner where
|
||||
possible. Complements fix_screen_no_taskbar (which only clamps height).
|
||||
"""
|
||||
for axis in ('Width', 'Height'):
|
||||
screen = config.get(f'screen.{axis.lower()}')
|
||||
avail = config.get(f'screen.avail{axis}')
|
||||
outer = config.get(f'window.outer{axis}')
|
||||
inner = config.get(f'window.inner{axis}')
|
||||
|
||||
# avail must not exceed screen
|
||||
if screen and avail and avail > screen:
|
||||
config[f'screen.avail{axis}'] = screen
|
||||
avail_clamped = config.get(f'screen.avail{axis}', screen)
|
||||
|
||||
# outer must not exceed avail (or screen if avail is unknown)
|
||||
outer_cap = avail_clamped if avail_clamped is not None else screen
|
||||
if outer and outer_cap and outer > outer_cap:
|
||||
chrome = max(0, outer - inner) if inner else 0
|
||||
config[f'window.outer{axis}'] = outer_cap
|
||||
if inner:
|
||||
config[f'window.inner{axis}'] = max(1, outer_cap - chrome)
|
||||
|
||||
# inner must not exceed outer
|
||||
outer_clamped = config.get(f'window.outer{axis}', outer)
|
||||
inner_now = config.get(f'window.inner{axis}')
|
||||
if inner_now and outer_clamped and inner_now > outer_clamped:
|
||||
config[f'window.inner{axis}'] = outer_clamped
|
||||
|
||||
|
||||
def set_media_devices_defaults(config: Dict[str, Any]) -> None:
|
||||
"""Spoof navigator.mediaDevices.enumerateDevices() so headless contexts
|
||||
expose a plausible device list.
|
||||
|
||||
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.
|
||||
"""
|
||||
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
|
||||
|
||||
|
||||
def _select_presets_file(ff_version: Optional[Any] = None) -> Path:
|
||||
"""Pick the bundled-presets file appropriate for a given Firefox version.
|
||||
|
||||
@@ -346,7 +583,9 @@ def from_preset(preset: Dict, ff_version: Optional[str] = None) -> Dict[str, Any
|
||||
config['voices'] = _generate_random_voice_subset(target_os)
|
||||
except Exception:
|
||||
if preset.get('speechVoices'):
|
||||
config['voices'] = preset['speechVoices']
|
||||
config['voices'] = _normalize_preset_voices(
|
||||
preset['speechVoices'], target_os
|
||||
)
|
||||
|
||||
return config
|
||||
|
||||
@@ -424,10 +663,13 @@ def _build_init_script(values: Dict[str, Any]) -> str:
|
||||
f' if (typeof w.setFontList === "function") w.setFontList({_json.dumps(joined)});'
|
||||
)
|
||||
|
||||
# Speech voices (comma-separated)
|
||||
# Speech voices (comma-separated names). config['voices'] holds MaskConfig
|
||||
# voice objects; extract the display name from each (tolerating a legacy
|
||||
# list of plain name strings).
|
||||
voices = values.get('speechVoices')
|
||||
if voices and len(voices) > 0:
|
||||
joined = ','.join(voices)
|
||||
names = [v['name'] if isinstance(v, dict) else v for v in voices]
|
||||
joined = ','.join(names)
|
||||
lines.append(
|
||||
f' if (typeof w.setSpeechVoices === "function") w.setSpeechVoices({_json.dumps(joined)});'
|
||||
)
|
||||
|
||||
@@ -14,7 +14,12 @@ from typing_extensions import Literal
|
||||
from camoufox.virtdisplay import VirtualDisplay
|
||||
|
||||
from .fingerprints import generate_context_fingerprint
|
||||
from .utils import launch_options, sync_attach_vd
|
||||
from .utils import (
|
||||
attach_no_viewport_default,
|
||||
launch_options,
|
||||
spoofs_window_dimensions,
|
||||
sync_attach_vd,
|
||||
)
|
||||
|
||||
|
||||
class Camoufox(PlaywrightContextManager):
|
||||
@@ -104,13 +109,21 @@ def NewBrowser(
|
||||
if not from_options:
|
||||
from_options = launch_options(headless=headless, debug=debug, **kwargs)
|
||||
|
||||
# Playwright's default viewport deadlocks Juggler when the window is spoofed
|
||||
# to a different size (daijro/camoufox#666), so default to no_viewport.
|
||||
no_viewport_default = spoofs_window_dimensions(from_options)
|
||||
|
||||
# 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)
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import os
|
||||
import sys
|
||||
from functools import wraps
|
||||
from os import environ
|
||||
from os.path import abspath
|
||||
from pathlib import Path
|
||||
@@ -20,7 +21,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
|
||||
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_window_dimensions, set_media_devices_defaults
|
||||
from .geolocation import geoip_allowed, get_geolocation
|
||||
from .ip import Proxy, public_ip, valid_ipv4, valid_ipv6
|
||||
from .locales import handle_locales
|
||||
@@ -345,6 +346,64 @@ def warn_manual_config(config: Dict[str, Any]) -> None:
|
||||
LeakWarning.warn('viewport', False)
|
||||
|
||||
|
||||
_WINDOW_DIM_KEYS = (
|
||||
'window.outerWidth',
|
||||
'window.outerHeight',
|
||||
'window.innerWidth',
|
||||
'window.innerHeight',
|
||||
'document.body.clientWidth',
|
||||
'document.body.clientHeight',
|
||||
)
|
||||
|
||||
|
||||
def spoofs_window_dimensions(from_options: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
Whether the CAMOU_CONFIG in a set of launch options spoofs any window
|
||||
dimension. The config is chunked across CAMOU_CONFIG_<n> env vars, so
|
||||
reassemble it in index order before looking.
|
||||
"""
|
||||
env = from_options.get('env') or {}
|
||||
chunks = [(int(k.rsplit('_', 1)[1]), v) for k, v in env.items() if k.startswith('CAMOU_CONFIG_')]
|
||||
if not chunks:
|
||||
return False
|
||||
blob = ''.join(v for _, v in sorted(chunks))
|
||||
return any(key in blob for key in _WINDOW_DIM_KEYS)
|
||||
|
||||
|
||||
def attach_no_viewport_default(target: Any) -> Any:
|
||||
"""
|
||||
Default new_page()/new_context() to no_viewport=True.
|
||||
|
||||
Playwright applies a 1280x720 viewport by default, which makes Juggler ask
|
||||
the content window to become 1280x720 (TargetRegistry.updateViewportSize).
|
||||
When Camoufox is pinning the window to a spoofed size, that request can
|
||||
never be satisfied, and awaitViewportDimensions has no timeout -- so the
|
||||
second new_page() hangs forever (daijro/camoufox#666).
|
||||
|
||||
With no_viewport, Juggler measures the window instead of resizing it, so the
|
||||
handshake resolves immediately and the page reports the spoofed dimensions
|
||||
exactly. Explicit viewport=/no_viewport= from the caller always wins.
|
||||
"""
|
||||
for name in ('new_page', 'new_context'):
|
||||
original = getattr(target, name, None)
|
||||
if original is None:
|
||||
continue
|
||||
|
||||
def wrap(original: Any) -> Any:
|
||||
@wraps(original)
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
if 'viewport' not in kwargs and 'no_viewport' not in kwargs:
|
||||
kwargs['no_viewport'] = True
|
||||
# Works for both sync and async: async returns the coroutine
|
||||
# unawaited, and the caller awaits it as usual.
|
||||
return original(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
setattr(target, name, wrap(original))
|
||||
return target
|
||||
|
||||
|
||||
async def async_attach_vd(
|
||||
browser: Any, virtual_display: Optional[VirtualDisplay] = None
|
||||
) -> Any: # type: ignore
|
||||
@@ -561,6 +620,13 @@ def launch_options(
|
||||
if not i_know_what_im_doing:
|
||||
warn_manual_config(config)
|
||||
|
||||
# Snapshot which domains the USER set before fingerprint generation fills in
|
||||
# the rest. The post-generation BrowserForge-correction fixes below must
|
||||
# only touch generated values, never override what the user passed.
|
||||
_user_set_navigator = is_domain_set(config, 'navigator.')
|
||||
_user_set_screen_window = is_domain_set(config, 'screen.', 'window.')
|
||||
_user_set_media_devices = is_domain_set(config, 'mediaDevices:')
|
||||
|
||||
# Assert the target OS is valid
|
||||
if os:
|
||||
check_valid_os(os)
|
||||
@@ -617,6 +683,14 @@ def launch_options(
|
||||
|
||||
target_os = get_target_os(config)
|
||||
|
||||
# Correct BrowserForge fingerprint inconsistencies that leak as headless /
|
||||
# impossible-geometry tells, unless the user is driving these themselves.
|
||||
if not _user_set_navigator:
|
||||
fix_navigator_arch(config, target_os)
|
||||
if not _user_set_screen_window:
|
||||
fix_screen_no_taskbar(config, target_os)
|
||||
clamp_window_dimensions(config)
|
||||
|
||||
# Set a random window.history.length
|
||||
set_into(config, 'window.history.length', randrange(1, 6)) # nosec
|
||||
|
||||
@@ -646,6 +720,11 @@ def launch_options(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Default mediaDevices to one mic + one camera so headless contexts don't
|
||||
# expose an empty enumerateDevices() list (a headless tell).
|
||||
if not _user_set_media_devices:
|
||||
set_media_devices_defaults(config)
|
||||
|
||||
# Set random seeds for fingerprint noise (per launch)
|
||||
set_into(config, 'fonts:spacing_seed', randint(1, 4_294_967_295)) # nosec
|
||||
set_into(config, 'audio:seed', randint(1, 4_294_967_295)) # nosec
|
||||
|
||||
@@ -246,5 +246,137 @@
|
||||
"Microsoft Tolga - Turkish (Turkey):tr-TR:local",
|
||||
"Microsoft Sarah Mobile - English (Great Britain):en-GB:local"
|
||||
],
|
||||
"lin": []
|
||||
"lin": [
|
||||
"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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"""
|
||||
Tests for the BrowserForge fingerprint-correction helpers in
|
||||
camoufox.fingerprints, ported to parity with the camoufox-js launcher.
|
||||
|
||||
Run with:
|
||||
cd pythonlib && python -m pytest tests/test_fingerprint_fixes.py -v
|
||||
|
||||
These guard the headless / impossible-geometry tells that BrowserForge
|
||||
occasionally ships and that the camoufox-js wrapper already corrected but the
|
||||
pythonlib did not.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
from camoufox.fingerprints import ( # noqa: E402
|
||||
clamp_window_dimensions,
|
||||
fix_navigator_arch,
|
||||
fix_screen_no_taskbar,
|
||||
set_media_devices_defaults,
|
||||
)
|
||||
|
||||
|
||||
class TestFixNavigatorArch:
|
||||
def test_corrects_armv81_to_ua_arch(self):
|
||||
c = {
|
||||
"navigator.userAgent": "Mozilla/5.0 (X11; Linux x86_64; rv:135.0) ...",
|
||||
"navigator.platform": "Linux armv81",
|
||||
"navigator.oscpu": "Linux armv81",
|
||||
}
|
||||
fix_navigator_arch(c, "lin")
|
||||
assert c["navigator.platform"] == "Linux x86_64"
|
||||
assert c["navigator.oscpu"] == "Linux x86_64"
|
||||
|
||||
def test_noop_when_already_consistent(self):
|
||||
c = {
|
||||
"navigator.userAgent": "... Linux x86_64 ...",
|
||||
"navigator.platform": "Linux x86_64",
|
||||
"navigator.oscpu": "Linux x86_64",
|
||||
}
|
||||
fix_navigator_arch(c, "lin")
|
||||
assert c["navigator.platform"] == "Linux x86_64"
|
||||
|
||||
def test_only_runs_on_linux(self):
|
||||
c = {"navigator.userAgent": "... Macintosh ...", "navigator.platform": "MacIntel"}
|
||||
fix_navigator_arch(c, "mac")
|
||||
assert c["navigator.platform"] == "MacIntel"
|
||||
|
||||
def test_noop_without_ua(self):
|
||||
c = {"navigator.platform": "Linux armv81"}
|
||||
fix_navigator_arch(c, "lin")
|
||||
assert c["navigator.platform"] == "Linux armv81"
|
||||
|
||||
|
||||
class TestFixScreenNoTaskbar:
|
||||
def test_subtracts_taskbar_when_avail_equals_screen(self):
|
||||
c = {
|
||||
"screen.width": 1920,
|
||||
"screen.height": 1080,
|
||||
"screen.availWidth": 1920,
|
||||
"screen.availHeight": 1080,
|
||||
"window.outerHeight": 1080,
|
||||
"window.innerHeight": 1040,
|
||||
}
|
||||
fix_screen_no_taskbar(c, "lin")
|
||||
assert c["screen.availHeight"] == 1080 - 27 # linux panel
|
||||
assert c["window.outerHeight"] == 1053
|
||||
# chrome delta (1080-1040=40) preserved
|
||||
assert c["window.innerHeight"] == 1053 - 40
|
||||
|
||||
def test_per_os_taskbar_height(self):
|
||||
for os_name, px in (("win", 40), ("mac", 25), ("lin", 27)):
|
||||
c = {
|
||||
"screen.width": 1920,
|
||||
"screen.height": 1080,
|
||||
"screen.availWidth": 1920,
|
||||
"screen.availHeight": 1080,
|
||||
}
|
||||
fix_screen_no_taskbar(c, os_name)
|
||||
assert c["screen.availHeight"] == 1080 - px
|
||||
|
||||
def test_noop_when_avail_already_less_than_screen(self):
|
||||
c = {
|
||||
"screen.width": 1920,
|
||||
"screen.height": 1080,
|
||||
"screen.availWidth": 1920,
|
||||
"screen.availHeight": 1040,
|
||||
}
|
||||
fix_screen_no_taskbar(c, "lin")
|
||||
assert c["screen.availHeight"] == 1040
|
||||
|
||||
|
||||
class TestClampWindowDimensions:
|
||||
def test_clamps_impossible_geometry_both_axes(self):
|
||||
c = {
|
||||
"screen.width": 1920,
|
||||
"screen.height": 1080,
|
||||
"screen.availWidth": 2000, # > screen
|
||||
"window.outerWidth": 2200, # > avail
|
||||
"window.innerWidth": 2100, # > outer
|
||||
}
|
||||
clamp_window_dimensions(c)
|
||||
assert c["screen.availWidth"] == 1920
|
||||
assert c["window.outerWidth"] == 1920
|
||||
assert c["window.innerWidth"] <= c["window.outerWidth"]
|
||||
|
||||
def test_preserves_chrome_delta(self):
|
||||
c = {
|
||||
"screen.width": 1000,
|
||||
"window.outerWidth": 1200, # 200 over screen
|
||||
"window.innerWidth": 1180, # 20px chrome
|
||||
}
|
||||
clamp_window_dimensions(c)
|
||||
assert c["window.outerWidth"] == 1000
|
||||
assert c["window.innerWidth"] == 1000 - 20
|
||||
|
||||
def test_noop_when_hierarchy_already_valid(self):
|
||||
c = {
|
||||
"screen.width": 1920,
|
||||
"screen.availWidth": 1920,
|
||||
"window.outerWidth": 1280,
|
||||
"window.innerWidth": 1264,
|
||||
}
|
||||
clamp_window_dimensions(c)
|
||||
assert c["window.outerWidth"] == 1280
|
||||
assert c["window.innerWidth"] == 1264
|
||||
|
||||
|
||||
class TestSetMediaDevicesDefaults:
|
||||
def test_sets_one_mic_one_cam(self):
|
||||
c = {}
|
||||
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
|
||||
|
||||
def test_respects_user_set_media_devices(self):
|
||||
c = {"mediaDevices:webcams": 5}
|
||||
set_media_devices_defaults(c)
|
||||
assert c == {"mediaDevices:webcams": 5}
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Driver-side guard for daijro/camoufox#666.
|
||||
|
||||
Playwright's implicit 1280x720 viewport makes Juggler ask a spoofed window to
|
||||
resize to a size it can never reach, deadlocking new_page(). The driver defaults
|
||||
to no_viewport whenever the config spoofs any window dimension, which fixes the
|
||||
hang on *already-released* browser builds -- no rebuild needed.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from camoufox.utils import attach_no_viewport_default, spoofs_window_dimensions
|
||||
|
||||
|
||||
def _opts(config_blob: str):
|
||||
"""Launch options with the config chunked the way launch_options() does."""
|
||||
chunks = [config_blob[i : i + 10] for i in range(0, len(config_blob), 10)] or [""]
|
||||
return {"env": {f"CAMOU_CONFIG_{i + 1}": c for i, c in enumerate(chunks)}}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"config, expected",
|
||||
[
|
||||
('{"window.outerWidth": 360}', True),
|
||||
('{"window.innerHeight": 740}', True),
|
||||
('{"document.body.clientWidth": 360}', True),
|
||||
('{"screen.width": 360}', False),
|
||||
('{"navigator.userAgent": "x"}', False),
|
||||
("{}", False),
|
||||
],
|
||||
)
|
||||
def test_detects_window_dimension_spoofing(config, expected):
|
||||
assert spoofs_window_dimensions(_opts(config)) is expected
|
||||
|
||||
|
||||
def test_reassembles_chunks_in_index_order():
|
||||
"""CAMOU_CONFIG_10 must not sort before CAMOU_CONFIG_2 -- a lexicographic
|
||||
join would corrupt the key we search for."""
|
||||
blob = '{"padding": "' + "x" * 200 + '", "window.outerWidth": 360}'
|
||||
assert spoofs_window_dimensions(_opts(blob)) is True
|
||||
|
||||
|
||||
def test_no_env_is_not_spoofed():
|
||||
assert spoofs_window_dimensions({}) is False
|
||||
|
||||
|
||||
class _FakeBrowser:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def new_page(self, **kwargs):
|
||||
self.calls.append(kwargs)
|
||||
return "page"
|
||||
|
||||
def new_context(self, **kwargs):
|
||||
self.calls.append(kwargs)
|
||||
return "context"
|
||||
|
||||
|
||||
def test_defaults_to_no_viewport():
|
||||
b = attach_no_viewport_default(_FakeBrowser())
|
||||
b.new_page()
|
||||
b.new_context()
|
||||
assert b.calls == [{"no_viewport": True}, {"no_viewport": True}]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"override",
|
||||
[{"viewport": {"width": 800, "height": 600}}, {"no_viewport": False}],
|
||||
)
|
||||
def test_explicit_caller_choice_always_wins(override):
|
||||
"""We must never override an explicit viewport decision."""
|
||||
b = attach_no_viewport_default(_FakeBrowser())
|
||||
b.new_page(**override)
|
||||
assert b.calls == [override]
|
||||
|
||||
|
||||
def test_other_kwargs_are_forwarded():
|
||||
b = attach_no_viewport_default(_FakeBrowser())
|
||||
b.new_page(locale="en-US")
|
||||
assert b.calls == [{"locale": "en-US", "no_viewport": True}]
|
||||
@@ -0,0 +1,103 @@
|
||||
"""
|
||||
Tests for camoufox.fingerprints speech-voice generation.
|
||||
|
||||
Mirrors camoufox-js/tests-camoufox-js/voices.test.ts.
|
||||
|
||||
Run with:
|
||||
cd pythonlib && python -m pytest tests/test_voices.py -v
|
||||
|
||||
The core regression these guard: every spoofable OS -- including Linux --
|
||||
must yield a non-empty list of MaskConfig voice OBJECTS (not raw
|
||||
"Name:lang:type" strings), or the C++ MaskConfig::MVoices() silently drops
|
||||
them and the host machine's native voices leak through.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
# Make `import camoufox` resolve to the in-tree pythonlib without an install.
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
from camoufox.fingerprints import ( # noqa: E402
|
||||
_generate_random_voice_subset,
|
||||
_normalize_preset_voices,
|
||||
)
|
||||
|
||||
_REQUIRED_FIELDS = {"lang", "name", "voiceUri", "isDefault", "isLocalService"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("target_os", ["macos", "windows", "linux"])
|
||||
def test_non_empty_for_every_os(target_os):
|
||||
voices = _generate_random_voice_subset(target_os, "en-US")
|
||||
assert len(voices) > 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("target_os", ["macos", "windows", "linux"])
|
||||
def test_entries_are_full_objects(target_os):
|
||||
# MaskConfig::MVoices() drops any entry missing a field, so every voice
|
||||
# must carry the full object shape.
|
||||
for v in _generate_random_voice_subset(target_os, "en-US"):
|
||||
assert isinstance(v, dict)
|
||||
assert _REQUIRED_FIELDS <= set(v.keys())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("target_os", ["macos", "windows", "linux"])
|
||||
def test_exactly_one_default(target_os):
|
||||
voices = _generate_random_voice_subset(target_os, "en-US")
|
||||
assert sum(1 for v in voices if v["isDefault"]) == 1
|
||||
|
||||
|
||||
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"
|
||||
|
||||
|
||||
class TestLinuxSpeechdUris:
|
||||
"""Linux voiceUris must match Firefox's SpeechDispatcherService.cpp:
|
||||
urn:moz-tts:speechd:<NS_EscapeURL(name, OnlyNonASCII|Spaces)>?<lang>
|
||||
"""
|
||||
|
||||
def setup_method(self):
|
||||
self.lin = _generate_random_voice_subset("linux", "en-US")
|
||||
|
||||
def test_prefix_and_lang_suffix(self):
|
||||
for v in self.lin:
|
||||
assert v["voiceUri"].startswith("urn:moz-tts:speechd:")
|
||||
assert v["voiceUri"].endswith("?" + v["lang"])
|
||||
|
||||
def test_spaces_escaped_punctuation_intact(self):
|
||||
gb = next(v for v in self.lin if v["name"] == "English (Great Britain)")
|
||||
assert gb["voiceUri"] == "urn:moz-tts:speechd:English%20(Great%20Britain)?en-GB"
|
||||
|
||||
def test_all_local_service(self):
|
||||
assert all(v["isLocalService"] for v in self.lin)
|
||||
|
||||
|
||||
def test_normalize_preset_voices_converts_strings():
|
||||
# Presets historically store "Name:lang:type" strings.
|
||||
out = _normalize_preset_voices(
|
||||
["Albert:en-US:local", "Alice:it-IT:local"], "macos"
|
||||
)
|
||||
assert all(_REQUIRED_FIELDS <= set(v.keys()) for v in out)
|
||||
assert out[0]["name"] == "Albert"
|
||||
assert out[0]["lang"] == "en-US"
|
||||
assert sum(1 for v in out if v["isDefault"]) == 1
|
||||
|
||||
|
||||
def test_normalize_preset_voices_passes_through_objects():
|
||||
obj = {
|
||||
"name": "Alex",
|
||||
"lang": "en-US",
|
||||
"voiceUri": "urn:moz-tts:osx:alex",
|
||||
"isDefault": True,
|
||||
"isLocalService": True,
|
||||
}
|
||||
out = _normalize_preset_voices([obj], "macos")
|
||||
assert out == [obj]
|
||||
|
||||
|
||||
def test_unknown_os_falls_back_to_macos():
|
||||
assert len(_generate_random_voice_subset("plan9", "en-US")) > 0
|
||||
Reference in New Issue
Block a user