mirror of
https://github.com/daijro/camoufox.git
synced 2026-10-04 16:00:21 +00:00
fix(python): warn whenever an identity falls back to a substitute value
Several draws swallowed their failure and used something else, so an
identity could ship with values the rest of it was not drawn to match and
nobody would hear about it:
- from_preset(): a failed font or voice draw used the preset's recorded
list, or nothing, on any exception.
- generate_context_fingerprint(): a failed font, voice or WebGL draw was
`except Exception: pass`, leaving the browser's launch-time values.
- _load_font_groups() / _load_font_bases(): an unreadable file became {},
i.e. no font additions or no OS-version base.
- launch_options(): a failed font draw used every font in fonts.json, a
failed voice draw used no voices, and a preset GPU missing from
webgl_data.db was silently swapped for a drawn one (36 of the 397
bundled presets).
Each site now catches only the errors its data can raise (OSError and
ValueError for an unreadable or corrupt file, KeyError for a manifest with
no entry for the OS, sqlite3.Error for the WebGL database) and emits a
FallbackWarning. The text names what failed and what the identity uses
instead, then gives a block to paste into an issue (camoufox, browser, OS
and Python versions, the error, and the identity's user agent or GPU),
asking the user to report it on GitHub. It shares LeakWarning's
caller-frame attribution and its template lives in warnings.yml.
The broad excepts had also been hiding a broken fixture:
test_launch_environment's font and voice stubs did not accept `seed`, so
every draw there raised and was swallowed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
9a42b6a17f
commit
165e68f831
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
+23
-20
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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"] == []
|
||||
|
||||
Reference in New Issue
Block a user