diff --git a/pythonlib/camoufox/_warnings.py b/pythonlib/camoufox/_warnings.py index 5df2e87..b9bde9a 100644 --- a/pythonlib/camoufox/_warnings.py +++ b/pythonlib/camoufox/_warnings.py @@ -1,13 +1,37 @@ import inspect +import platform import warnings +from importlib.metadata import PackageNotFoundError, version from pathlib import Path -from typing import Optional +from typing import Optional, Type from camoufox.pkgman import load_yaml WARNINGS_DATA = load_yaml('warnings.yml') +def _warn_from_caller(message: str, category: Type[Warning]) -> None: + """Attribute the warning to the first frame outside this package, so it + points at the user's call rather than at camoufox internals.""" + current_module = Path(__file__).parent + frame = inspect.currentframe() + while frame: + if not Path(frame.f_code.co_filename).is_relative_to(current_module): + break + frame = frame.f_back + + if frame: + warnings.warn_explicit( + message, + category=category, + filename=frame.f_code.co_filename, + lineno=frame.f_lineno, + ) + return + + warnings.warn(message, category=category) + + class LeakWarning(RuntimeWarning): """ Raised when a the user has a setting enabled that can cause detection. @@ -23,22 +47,45 @@ class LeakWarning(RuntimeWarning): return if i_know_what_im_doing is not None: warning += '\nIf this is intentional, pass `i_know_what_im_doing=True`.' + _warn_from_caller(warning, LeakWarning) - # Get caller information - current_module = Path(__file__).parent - frame = inspect.currentframe() - while frame: - if not Path(frame.f_code.co_filename).is_relative_to(current_module): - break - frame = frame.f_back - if frame: - warnings.warn_explicit( - warning, - category=LeakWarning, - filename=frame.f_code.co_filename, - lineno=frame.f_lineno, - ) - return +def _browser_version() -> str: + from camoufox.exceptions import CamoufoxNotInstalled + from camoufox.pkgman import installed_verstr - warnings.warn(warning, category=LeakWarning) + try: + return installed_verstr() + except CamoufoxNotInstalled: + return 'not installed' + + +class FallbackWarning(RuntimeWarning): + """ + Raised when part of an identity could not be drawn and a substitute was used. + """ + + @staticmethod + def warn(what: str, instead: str, error: Exception, identity: Optional[str] = None) -> None: + """ + Warns that `what` failed with `error` and the identity uses `instead`, + with a block of versions and the error for the user to paste into an issue. + """ + try: + camoufox_version = version('camoufox') + except PackageNotFoundError: + camoufox_version = 'source checkout' + lines = [ + f'camoufox: {camoufox_version}', + f'browser: {_browser_version()}', + f'os: {platform.platform()}', + f'python: {platform.python_version()}', + f'error: {type(error).__name__}: {error}', + ] + if identity: + lines.append(f'identity: {identity}') + report = '\n'.join(f' {line}' for line in lines) + _warn_from_caller( + WARNINGS_DATA['fallback'].format(what=what, instead=instead, report=report), + FallbackWarning, + ) diff --git a/pythonlib/camoufox/fingerprints.py b/pythonlib/camoufox/fingerprints.py index 2c467aa..752076c 100644 --- a/pythonlib/camoufox/fingerprints.py +++ b/pythonlib/camoufox/fingerprints.py @@ -3,12 +3,14 @@ import json import os import re import secrets +import sqlite3 import unicodedata from dataclasses import asdict, dataclass, is_dataclass from pathlib import Path from random import Random, choice, randint, randrange from typing import Any, Dict, FrozenSet, List, Optional, Set, Tuple +from camoufox._warnings import FallbackWarning from camoufox.pkgman import load_yaml from camoufox.webgl import sample_webgl @@ -399,7 +401,10 @@ def _load_font_groups() -> Dict[str, List[Dict[str, Any]]]: try: with open(path, 'rb') as f: _FONT_GROUPS_CACHE = json.loads(f.read()) - except (OSError, ValueError): + except (OSError, ValueError) as e: + FallbackWarning.warn( + 'Reading font-groups.json', 'an OS-version base with no font additions', e + ) _FONT_GROUPS_CACHE = {} return _FONT_GROUPS_CACHE @@ -422,7 +427,10 @@ def _load_font_bases() -> Dict[str, List[Dict[str, Any]]]: try: with open(path, 'rb') as f: _FONT_BASES_CACHE = json.loads(f.read()) - except (OSError, ValueError): + except (OSError, ValueError) as e: + FallbackWarning.warn( + 'Reading font-bases.json', 'only the always-present core fonts as its OS base', e + ) _FONT_BASES_CACHE = {} return _FONT_BASES_CACHE @@ -1660,10 +1668,16 @@ def from_preset(preset: Dict, ff_version: Optional[str] = None, salt: Optional[i target_os = 'linux' else: target_os = 'macos' + preset_key = f"{config.get('navigator.userAgent')} / {config.get('webGl:renderer')}" try: config['fonts'] = _generate_random_font_subset(target_os, seed=identity_seed(config, salt)) - except Exception: - # Fallback to preset fonts if font generation fails + except (OSError, ValueError) as e: + FallbackWarning.warn( + 'Drawing the font list', + "the preset's recorded fonts" if preset.get('fonts') else "the browser's own fonts", + e, + preset_key, + ) if preset.get('fonts'): fonts = list(preset['fonts']) _ensure_marker_fonts(fonts, { @@ -1675,7 +1689,13 @@ def from_preset(preset: Dict, ff_version: Optional[str] = None, salt: Optional[i # Generate a unique random voice subset from the OS voice list try: config['voices'] = _generate_random_voice_subset(target_os, seed=identity_seed(config, salt)) - except Exception: + except (OSError, ValueError, KeyError) as e: + FallbackWarning.warn( + 'Drawing the speech voices', + "the preset's recorded voices" if preset.get('speechVoices') else "the browser's own voices", + e, + preset_key, + ) if preset.get('speechVoices'): config['voices'] = _normalize_preset_voices( preset['speechVoices'], target_os @@ -1825,15 +1845,21 @@ def generate_context_fingerprint( if 'fonts' not in config: try: config['fonts'] = _generate_random_font_subset(os_name, seed=identity_seed(config, _salt)) - except Exception: - pass + except (OSError, ValueError) as e: + FallbackWarning.warn( + 'Drawing the font list', "the browser's launch-time fonts", e, + config.get('navigator.userAgent'), + ) # Add voices (fpgen.yml does not map these yet) if 'voices' not in config: try: config['voices'] = _generate_random_voice_subset(os_name, seed=identity_seed(config, _salt)) - except Exception: - pass + except (OSError, ValueError, KeyError) as e: + FallbackWarning.warn( + 'Drawing the speech voices', "the browser's launch-time voices", e, + config.get('navigator.userAgent'), + ) # Derive oscpu if the fingerprint didn't provide it if 'navigator.oscpu' not in config: @@ -1857,19 +1883,23 @@ def generate_context_fingerprint( _target_os = 'lin' else: _target_os = 'mac' + # Same coherence treatment launch_options applies (#729): lift + # netbook geometry, then keep the GPU consistent with whatever + # screen this identity ended up with. This path has no real + # display to reconcile against, so the floor is unconditional. + raise_screen_to_modern_floor(config) try: - # Same coherence treatment launch_options applies (#729): lift - # netbook geometry, then keep the GPU consistent with whatever - # screen this identity ended up with. This path has no real - # display to reconcile against, so the floor is unconditional. - raise_screen_to_modern_floor(config) webgl_fp = sample_webgl_for_screen( _target_os, config.get('screen.width'), config.get('screen.height') ) + except (ValueError, sqlite3.Error) as e: + FallbackWarning.warn( + 'Drawing the WebGL GPU', "the browser's launch-time GPU", e, + config.get('navigator.userAgent'), + ) + else: webgl_fp.pop('webGl2Enabled', None) config.update(webgl_fp) - except Exception: - pass # Build source dicts from the fingerprint config for init_values nav = { diff --git a/pythonlib/camoufox/utils.py b/pythonlib/camoufox/utils.py index ba2f437..38a6910 100644 --- a/pythonlib/camoufox/utils.py +++ b/pythonlib/camoufox/utils.py @@ -40,7 +40,7 @@ from .pkgman import ( launch_path, ) from .virtdisplay import VirtualDisplay -from ._warnings import LeakWarning +from ._warnings import FallbackWarning, LeakWarning from .webgl import sample_webgl ListOrString: TypeAlias = Union[Tuple[str, ...], List[str], str] @@ -1146,7 +1146,11 @@ def launch_options( # OS base is claimed native=(target_os in ('mac', 'win') and _host_os_key() == target_os), ) - except Exception: + except (OSError, ValueError) as e: + FallbackWarning.warn( + 'Drawing the font list', f"every font fonts.json lists for {target_os}", e, + config.get('navigator.userAgent'), + ) update_fonts(config, target_os) # Draw the identity's media devices (counts + OS-style labels/groups from @@ -1374,10 +1378,13 @@ def launch_options( config['voices'] = _generate_random_voice_subset( os_name_v, voice_locale, seed=identity_seed(config, _identity_salt) ) - except Exception: + except (OSError, ValueError, KeyError) as e: # An empty list still blocks the host's voices (see below), so a # generation failure degrades to "no voices" rather than "all of # the host's". + FallbackWarning.warn( + 'Drawing the speech voices', 'no speech voices', e, config.get('navigator.userAgent') + ) config['voices'] = [] # Pin the block explicitly instead of relying on a non-empty list to imply @@ -1423,21 +1430,12 @@ def launch_options( # Preset already set vendor/renderer — sample matching WebGL params try: webgl_fp = sample_webgl(target_os, config['webGl:vendor'], config['webGl:renderer'], seed=identity_seed(config, _identity_salt)) - except ValueError: - # The pair is not in webgl_data.db, which holds 31 GPUs. 39 of the - # 435 bundled presets name one it does not have -- including rows - # that cannot be the OS they are filed under, e.g. a Windows - # preset claiming "ANGLE (Unknown, Adreno (TM) 650 ...)", a phone - # GPU. Raising here made launch_options() fail outright for ~9% of - # presets, and a caller passing their own preset dict had no way - # to know which pairs are supported. - # - # There is no way to keep the named GPU: the parameters, extension - # list and shader precisions all have to come from a real recorded - # device, and there is none for an unknown renderer. So draw a GPU - # that fits the screen and let it replace the pair -- the identity - # loses the preset's GPU string but stays internally coherent, - # which is the property that matters to a page reading both. + except ValueError as e: + # The pair is not in webgl_data.db, which holds 31 GPUs; 36 of the + # 397 bundled presets name one it does not have. The parameters, + # extension list and shader precisions must all come from one + # recorded device, and there is none for this renderer, so the + # identity takes a GPU drawn to fit the screen instead. webgl_fp = sample_webgl_for_screen( target_os, config.get('screen.width'), config.get('screen.height'), seed=identity_seed(config, _identity_salt), @@ -1447,8 +1445,13 @@ def launch_options( # preset's renderer string with another device's parameters, # extensions and shader precisions behind it -- a mismatch louder # than the unknown GPU we are replacing. - config.pop('webGl:vendor', None) - config.pop('webGl:renderer', None) + preset_gpu = f"{config.pop('webGl:vendor', None)} / {config.pop('webGl:renderer', None)}" + FallbackWarning.warn( + f"Finding the preset's GPU ({preset_gpu}) in webgl_data.db", + f"a GPU drawn to fit the screen ({webgl_fp['webGl:renderer']})", + e, + config.get('navigator.userAgent'), + ) else: # Synthetic path: keep the GPU coherent with the screen BrowserForge # already picked. Sampling the two independently yields pairs no diff --git a/pythonlib/camoufox/warnings.yml b/pythonlib/camoufox/warnings.yml index 2a51e9e..da49b9a 100644 --- a/pythonlib/camoufox/warnings.yml +++ b/pythonlib/camoufox/warnings.yml @@ -61,3 +61,10 @@ max_touch_points: >- `(pointer: coarse)` false and `ontouchstart` absent, exactly as a real one does. The rest of your fingerprint is not adjusted to suit, so a device that claims a digitizer but reports a screen size no touchscreen laptop ships with is still inconsistent. + +fallback: |- + {what} failed, so this identity uses {instead} instead. + A substitute is not drawn to match the rest of the identity, and a page may be able to tell. + Please report this at https://github.com/daijro/camoufox/issues/new and include: + + {report} diff --git a/pythonlib/tests/test_fallback_warnings.py b/pythonlib/tests/test_fallback_warnings.py new file mode 100644 index 0000000..ec7c847 --- /dev/null +++ b/pythonlib/tests/test_fallback_warnings.py @@ -0,0 +1,96 @@ +"""Every place an identity falls back to a substitute value says so. + +A substitute is a value the rest of the identity was not drawn to match, which +a page can see. Each site warns with a report block the user can paste into a +GitHub issue, so the failure reaches us instead of shipping silently. +""" + +import sqlite3 + +import pytest +from test_identity_salt import launch + +from camoufox import fingerprints as fp +from camoufox import utils +from camoufox._warnings import FallbackWarning + +REPORT = r"Please report this at https://github\.com/daijro/camoufox/issues/new" + + +def _fail(error): + def raiser(*_args, **_kwargs): + raise error + + return raiser + + +def _preset(): + preset = fp.load_presets("152")["presets"]["windows"][0] + return {**preset, "fonts": ["Arial"]} + + +def _report(record): + (warning,) = [w for w in record if w.category is FallbackWarning] + text = str(warning.message) + assert "camoufox:" in text and "python:" in text and "os:" in text + return text + + +def test_preset_font_draw(monkeypatch): + monkeypatch.setattr(fp, "_generate_random_font_subset", _fail(OSError("fonts.json missing"))) + with pytest.warns(FallbackWarning, match=REPORT) as record: + config = fp.from_preset(_preset(), "152") + assert "OSError: fonts.json missing" in _report(record) + assert "Arial" in config["fonts"] + + +def test_preset_voice_draw(monkeypatch): + monkeypatch.setattr(fp, "_generate_random_voice_subset", _fail(ValueError("bad manifest"))) + with pytest.warns(FallbackWarning, match=REPORT) as record: + fp.from_preset(_preset(), "152") + assert "ValueError: bad manifest" in _report(record) + + +@pytest.mark.parametrize( + "target, error, key", + [ + ("_generate_random_font_subset", OSError("fonts.json missing"), "fonts"), + ("_generate_random_voice_subset", ValueError("bad manifest"), "voices"), + ("sample_webgl_for_screen", sqlite3.OperationalError("no such table"), "webGl:renderer"), + ], +) +def test_context_draws(monkeypatch, target, error, key): + monkeypatch.setattr(fp, target, _fail(error)) + with pytest.warns(FallbackWarning, match=REPORT) as record: + context = fp.generate_context_fingerprint(os="linux") + assert f"{type(error).__name__}: {error}" in _report(record) + assert key not in context["config"] + + +@pytest.mark.parametrize( + "loader, cache", [("_load_font_groups", "_FONT_GROUPS_CACHE"), ("_load_font_bases", "_FONT_BASES_CACHE")] +) +def test_font_data_loaders(monkeypatch, tmp_path, loader, cache): + monkeypatch.setattr(fp, cache, None) + monkeypatch.setattr(fp, "__file__", str(tmp_path / "fingerprints.py")) + with pytest.warns(FallbackWarning, match=REPORT) as record: + assert getattr(fp, loader)() == {} + assert "FileNotFoundError" in _report(record) + + +def test_launch_font_draw(monkeypatch): + monkeypatch.setattr(utils, "_generate_random_font_subset", _fail(OSError("font-bases.json missing"))) + with pytest.warns(FallbackWarning, match=REPORT): + config = launch() + assert config["fonts"] + + +def test_preset_gpu_not_in_webgl_data(): + # Windows reports every GPU through ANGLE, so only an ANGLE string reaches the lookup. + GPU = "ANGLE (Acme, Acme GPU 9000 Direct3D11 vs_5_0 ps_5_0)" + preset = fp.load_presets("152")["presets"]["windows"][0] + preset = {**preset, "webgl": {"unmaskedVendor": "Google Inc. (Acme)", "unmaskedRenderer": GPU}} + with pytest.warns(FallbackWarning, match=REPORT) as record: + config = launch(os="windows", fingerprint_preset=preset) + assert GPU in _report(record) + assert config["webGl:renderer"] != GPU diff --git a/pythonlib/tests/test_launch_environment.py b/pythonlib/tests/test_launch_environment.py index 2505395..02cceef 100644 --- a/pythonlib/tests/test_launch_environment.py +++ b/pythonlib/tests/test_launch_environment.py @@ -14,8 +14,8 @@ def isolated_launch_dependencies(monkeypatch): monkeypatch.setattr(utils, "generate_fingerprint", lambda *args, **kwargs: object()) monkeypatch.setattr(utils, "from_fpgen", lambda *args, **kwargs: {}) monkeypatch.setattr(utils, "get_screen_cons", lambda *args, **kwargs: None) - monkeypatch.setattr(utils, "_generate_random_font_subset", lambda *args: []) - monkeypatch.setattr(utils, "_generate_random_voice_subset", lambda *args: []) + monkeypatch.setattr(utils, "_generate_random_font_subset", lambda *args, **kwargs: []) + monkeypatch.setattr(utils, "_generate_random_voice_subset", lambda *args, **kwargs: []) monkeypatch.setattr(utils, "fix_navigator_arch", lambda *args: None) monkeypatch.setattr(utils, "fix_screen_no_taskbar", lambda *args: None) monkeypatch.setattr(utils, "clamp_window_dimensions", lambda *args: None) diff --git a/pythonlib/tests/test_voices.py b/pythonlib/tests/test_voices.py index 3f55264..529101d 100644 --- a/pythonlib/tests/test_voices.py +++ b/pythonlib/tests/test_voices.py @@ -152,11 +152,14 @@ def test_caller_can_override_the_block_flag(): def test_voice_generation_failure_fails_closed(monkeypatch): import camoufox.utils as utils + from camoufox._warnings import FallbackWarning + def boom(*_args, **_kwargs): - raise RuntimeError("voice-manifests.json unreadable") + raise OSError("voice-manifests.json unreadable") monkeypatch.setattr(utils, "_generate_random_voice_subset", boom) - cfg = _launch_config(os="macos") + with pytest.warns(FallbackWarning, match="github.com/daijro/camoufox/issues/new"): + cfg = _launch_config(os="macos") # An empty list plus the block flag means "no voices" -- never "all of the # host's". assert cfg["voices"] == []