Docs that match the code, one AGENTS.md, dead code removed, and the audit's bug fixes (#787)

* chore: remove build tooling nothing uses

- The developer UI (scripts/developer.py, `make edits`). It depended on
  easygui, which no requirements file declares, and every action it offered is
  a Makefile target: patch, unpatch, workspace, revert, diff. Its two helpers in
  scripts/_mixin.py (is_bootstrap_patch, patch) had no other callers.
- legacy/, the Go launcher deprecated in 2024-11. Nothing built or shipped it.
  Its Makefile targets and scripts/run-pw.py go with it, and so does Go from
  every dependency list and workflow.
- jsonvv/ and settings/camoucfg.jvv. Nothing read the .jvv schema: config is
  validated against settings/properties.json, and the two had already drifted.
  The jsonvv package stays on PyPI.
- Scripts with no caller: bootstrap.py, moztree, setup-wasi-linux.sh,
  package-helper.sh, install-local-build.sh, mozfetch.sh (copied into lw/ but
  never packaged), examples/.
- The pre-ESM Juggler copies JugglerFrameParent.jsm and JugglerFrameChild.jsm,
  and hidden-scrollbars.css. Juggler loads the .sys.mjs actors and deliberately
  no stylesheet, but jar.mn still packaged all three.
- patches/librewolf/*.opt, which list_patches() never picks up; the roverfox
  second pass in patch.py, whose directory no longer exists; the unread
  --no-settings-pane option.
- The CAMOUFOX_PASSWD secret passed to `make fetch` and closedsrc_rev in
  upstream.sh, which nothing reads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(python): remove dead helpers and a stale dependency

None of these had a caller:
- pkgman: is_supported_path, extract_zip, cleanup and set_version, left over
  from the single-directory install. cleanup() would have deleted every
  installed browser version.
- multiversion.get_cached_repo_names, CONSTRAINTS.as_range,
  fingerprints._load_os_voices, utils._clean_locals, and unused imports.

Also:
- The "Apify Fingerprints" row in `camoufox version`, which has read "?"
  since fpgen replaced BrowserForge.
- lxml is no longer a dependency; nothing imports it.
- The geoip extra now names maxminddb, the module geolocation.py actually
  imports, rather than getting it transitively through geoip2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: show the cursor paths humanize=True actually produces

The README's cursor video showed the Bezier generator Camoufox replaced
with Cursory's recorded trajectories. scripts/cursor-demo.py drives a real
build with humanize=True and records every mousemove event the page
receives. It writes assets/humanize-cursor.svg, an animated replay at the
recorded speed, so what the figure shows is what a site sees.

The script cannot change the binary, so ci/browser_inputs.py lists it as
non-native and editing it does not invalidate the cached browser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(python): stop naming BrowserForge in user-facing text

fpgen replaced BrowserForge, but two LeakWarnings, the NonFirefoxFingerprint
message and the fingerprint_preset docstring still named it. One warning also
linked to a README anchor that no longer exists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: one AGENTS.md for every agent, a roadmap, and docs that match the code

- AGENTS.md holds the engineering rules for any coding agent, plus the
  repo map, build, patch and test commands that CLAUDE.md used to carry.
  CLAUDE.md now only imports it, so there is one set of rules.
  ci/tribal-rules.yml is the record of settled decisions it points to.
- ROADMAP.md lists planned work, each item linked to its issue.
- README:
  - fpgen and the coherence check replace BrowserForge;
  - the patch workflow uses the make targets instead of the removed
    developer UI;
  - letter-spacing noise is described as off by default, as it is.
- docs/:
  - beta-testing-ff146.md removed;
  - patch-upgrading-guide rewritten around the make targets;
  - per-context-patches without the canvas patch that no longer exists,
    and with measured preset counts;
  - playwright-maintenance without the JSM wrapper that does not exist;
  - smaller fixes in MEDIA-DEVICES, input-dispatch and FONTS.
- ci/README: every job, and the real shard, skiplist and entry-point lists.
- pythonlib, tester and patch-dependency READMEs corrected against the code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(pythonlib): handle headless='virtual' in launch_server

launch_server() is documented to take the same arguments as Camoufox(),
but passed headless='virtual' straight to launch_options(), so the server
launched with no Xvfb display. Start a VirtualDisplay the way Camoufox()
does, launch headful on it, and kill it when the server process exits or
the launch fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(python): remove fontprobe, which nothing called

fontprobe listed the fonts installed on the host, for a `camoufox fonts`
command that was never added. It has nothing to do with the font bundle
Camoufox serves to pages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(license): the Python launcher is MIT; the browser stays MPL-2.0

The Python package has always been published to PyPI as MIT (#727), but
pythonlib/ shipped no licence file, and the repo's LICENSE is the browser's
MPL-2.0. MPL is copyleft per file. It covers the modified Firefox sources, not
a separate launcher that drives the browser over Playwright. So
pythonlib/LICENSE now carries the MIT text its metadata already declares, and
a Licensing section in the README says which part is which.

Closes #727.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(python): fingerprint_preset=False no longer turns presets on

launch_options checked `fingerprint_preset is not None`, so passing False
drew a random bundled preset, the opposite of what was asked. It now uses a
truthiness check, and a test proves that None and False never draw a preset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ci: bring the release workflow in line with the tests build job

build.yml had drifted from tests.yml. It ran actions at v1/v2 on a
retired Node runtime, prepared the source tree with bare make calls
that fail the whole release on one dropped connection, and built with
a different Python than every pull request is tested with.

- Pin every action by commit SHA, at the major versions tests.yml uses
  (checkout v4, setup-python v5, upload/download-artifact v4, the same
  remove-unwanted-software SHA), and action-gh-release v2. The release
  job holds contents: write, so it should not follow a movable tag.
- Prepare the tree with `python3 -m ci.run_prepare`, as the tests build
  job does. BUILD_TARGET is set from the matrix so `make dir` writes the
  right mozconfig and Rust targets; multibuild.py then finds _READY and
  builds without re-patching. mach's toolchain bootstrap ignores the
  mozconfig, so running it after `dir` bootstraps the same toolchains.
- Build with Python 3.12, the version the tests build job compiles with.
- Default the workflow to no permissions; the build job gets
  contents: read and the release job keeps contents: write.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(python): NewContext looks up a proxy's exit IP through the right URL, or fails

NewContext derives the context's WebRTC IP and timezone from the proxy's exit
IP. That lookup had two defects, and both left the context showing the
host's values while its traffic went through the proxy:

- It built its own proxy URL with urlparse, which reads a scheme-less server
  such as "1.2.3.4:8080" (a form Playwright accepts) as scheme "1.2.3.4" with
  no host. urllib could not use a SOCKS proxy at all.
- Any failure was swallowed, and the context opened without the values.

The URL is now built with Proxy.as_string(), which the geoip launch path
already uses (scheme-less means http). The lookup goes through requests,
which handles SOCKS, and a failed lookup raises InvalidIP, naming the two
options that skip it. The tests cover scheme-less, http and socks5 servers
with credentials, both failure modes, and the case where no lookup is needed,
for NewContext and AsyncNewContext.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: stop generating a canvas seed, and drop config keys nothing reads

The browser has not noised the canvas since #528, and no patch reads
canvas:seed (#721). The launcher still drew one on every launch and sent it
through CAMOU_CONFIG, and NewContext called a setCanvasSeed that does not
exist. They no longer do.

For users this changes nothing on any browser since #528: the value was
ignored. A config that still passes canvas:seed gets the usual "Skipping
unknown patch" notice instead of silence. On a browser from before #528, the
launcher no longer turns canvas noise on, which is the behaviour #528 chose.

The same audit found more keys declared in settings/properties.json that no
patch or Juggler file reads, so setting them did nothing:
- canvas:aaOffset, canvas:aaCapOffset
- memorysaver, pdfViewerEnabled, webrtc:localipv4/6
- navigator.onLine, navigator.cookieEnabled, navigator.languages
- navigator.appCodeName, appName, product, productSub. Firefox reports these
  constants itself, so fpgen.yml no longer maps them.
- webGl:parameters:blockIfNotDefined and its WebGL2 twin

test_config_schema now checks this direction too: every declared key must be
read by the browser, unless it is listed with a reason. Three are listed:
locale:script and navigator.doNotTrack, which the launcher applies itself,
and navigator.buildID (#780).

The build-tester grading followed the same wrong premise. It tracked canvas
collisions as an unfixed per-context leak. A canvas that is rendered rather
than noised follows the fonts and GPU, as it does on real machines, so canvas
collisions are now counted with the other device-level values. The tribal rule
that recorded it as an open question is now a settled one,
canvas-is-not-noised, with an automated check.

Closes #721.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(python): repair devicePixelRatio the same way on every launch

The DPR repair snaps an off-grid ratio to the nearest real scaling step and
keeps the first of two equally near steps. The steps were frozenset
literals, and a frozenset literal iterates in one order when the module is
compiled from source and another when it is loaded back from a .pyc. So a
midpoint such as 1.125 became 1.25 on the first launch after an install and
1 on every launch after it: the same pinned identity presented two different
devicePixelRatio values.

The steps are now ascending tuples, so a tie always goes to the lower step.
The test runs the repair in two fresh interpreters that share a bytecode
cache, compiling in the first and loading in the second. It failed before
this change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: remove the glyph-spacing seed from the browser and the launcher

anti-font-fingerprinting.patch added a seeded amount to every glyph advance,
so that text widths differed per context. No real machine produces those
widths: the same font on the same OS measures the same everywhere. So the
noise was itself a fingerprint, measured in #779 at +1 px per ~100 glyphs
plus fractional deltas on every measureText. #779 defaulted the seed to 0
and kept it as an opt-in, but an opt-in whose only effect is to become
detectable is not worth carrying. Removed:

- The browser side:
  - FontSpacingSeedManager and window.setFontSpacingSeed;
  - the HarfBuzz hook;
  - the plumbing that existed only to carry the context id down to the
    shaper: the userContextId on gfxTextRun, gfxShapedWord and the word-cache
    key, and the extra MakeTextRun argument in nsTextFrame, nsFontMetrics,
    MathML and canvas.
  The font group keeps its userContextId, which font-list-spoofing.patch
  uses to apply the per-context font list. Text is now shaped exactly as
  stock Firefox shapes it.
- The fonts:spacing_seed key. The launcher had been sending 0 on every
  launch, plus a setFontSpacingSeed(0) call in every context's init script.
- tests/patches/config-overrides.py, which tested only the spacing override.
  A pythonlib test now covers config_overrides with another key.

timezone-spoofing, webrtc-ip-spoofing and window-setter-seal change only in
context lines and the setter seal list. Every patch applies cleanly to a
fresh tree, and the result builds. The settled decision is recorded as
no-glyph-spacing-noise, with an automated check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: stock animations and speech by default; drop config keys that freeze live values

Three behaviours a page could detect, changed in one breaking release:

- **Animations run on stock timing.** no-css-animations.patch finished every
  finite animation at once by default, and any page could read it:
  `el.animate(frames, 1000).effect.getComputedTiming().duration` was 0, and a
  500ms transition reported 0. Measured on v152.0.4-beta.31. The speedup is
  now an opt-in, `instantAnimations: True`, which raises a LeakWarning.
  disableInstantAnimations is gone.
- **speak() on a spoofed voice works like a real voice.** It fired `error`
  after 3ms unless voices:fakeCompletion was set, and then start and end in
  the same tick. It now starts and ends after the text's duration at ~150
  words per minute. Both voices:fakeCompletion keys are gone, and so is a
  debug line printed to stderr on every call.
- **Keys removed:**
  - battery:* and window.scrollMinX/Y: Firefox keeps getBattery() and
    scrollMin* chrome-only, so no page could read them.
  - window.scrollMaxX/Y, screen.pageXOffset/pageYOffset,
    window.history.length and document.body.client*: each pinned a live value
    to a constant, so scrolling, navigating or re-laying out never changed it.
    fpgen.yml mapped pageYOffset, so about 15% of identities froze
    window.scrollY at a non-zero value.
  - The body keys' role as an undocumented alias for window.innerWidth/Height
    in browser-init and in the launcher.
  - MaskConfig::GetInt32Rect, which only the body keys used.

New guards, both of which fail on v152.0.4-beta.31:
tests/patches/animation-timing.py and tests/patches/spoofed-voice-speaks.py.
The decisions are recorded as animations-run-on-stock-timing and
spoofed-voices-speak. Every patch applies cleanly to a fresh tree, and the
result builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(python)!: remove dead public API and make `list all --path` work

Breaking changes:

- Remove the exceptions UnknownProperty, InvalidDebugPort and
  MissingDebugPort. Nothing in the package raises them, so code catching
  them was catching nothing.
- Remove the legacy `allow_webgl` keyword of launch_options(). Use
  `block_webgl=True`. The keyword now reaches Playwright as an unknown
  launch option and fails there instead of being silently consumed.

`camoufox list all --path` accepted the flag and ignored it. It now prints
the install path beside each installed build, as `camoufox list --path`
already does for the installed tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(python)!: drop data the package never draws from

voices.json shipped in the wheel, but no code in the package reads it: the
voice draw uses voice-manifests.json and voice-uris.json. Its only readers
are the TypeScript port's golden-fixture generator and data-sync script
(typescript/scripts/golden/identity_golden.py,
typescript/scripts/sync-identity-data.py), which live on another branch and
will need a new source; the last copy is at
676fb3f:pythonlib/camoufox/voices.json. docs/per-context-patches.md
described it as runtime data and now describes the files that are.

webgl_data.db held two rows with zero weight on every OS ("Intel(R) HD
Graphics 400, or similar" from "Intel Inc." and "Radeon R9 200 Series, or
similar" from "ATI Technologies Inc."), left behind when their impossible
macOS weights were zeroed. No draw can reach them. They are deleted with
secure_delete so their blobs do not linger in free pages; the file is not
vacuumed, so the other pages are unchanged. A new test requires every row
to be drawable on at least one OS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 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>

* fix(python): give NewContext identities the browser's Firefox version

NewContext() and AsyncNewContext() passed ff_version=None through to
generate_context_fingerprint(), so a context's user agent kept the version
fpgen drew (e.g. Firefox/146) while the browser underneath was 152. They
now default ff_version to the major version of Playwright's
Browser.version, which Juggler reports from MOZ_APP_VERSION_DISPLAY, so the
UA always names the browser the page is actually talking to. An explicit
ff_version still wins.

The docstrings said each context gets "its own real fingerprint preset";
the default has been an fpgen draw, with a preset only when one is passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(python): send an IPv6 WebRTC address to setWebRTCIPv6

The per-context init script passed every webrtc_ip, IPv6 included, to
window.setWebRTCIPv4(), and never called setWebRTCIPv6(). An IPv6 address
(given directly, or resolved as a proxy's exit IP) was stored as the
context's IPv4 value and the IPv6 slot stayed empty. The script now picks
the setter by address family, and an address that is neither raises
InvalidIP instead of being passed through.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(python): stop pinning the page's scroll offset from fpgen

fpgen.yml mapped the drawn window.pageYOffset (e.g. 528) to
screen.pageYOffset, and the browser returns that value from scrollY on
every read, so a page saw one scroll position forever whatever the user
did. Real scroll offsets are live page state, not part of a device's
fingerprint, so neither offset is mapped any more.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(python): stop checking a config key that no longer exists

warn_manual_config() looked for navigator.languages, which was removed
from settings/properties.json; validate_config() rejects it before the
check could matter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(python): close the WebGL database connection on every path

sample_webgl raised its not-found and wrong-OS errors before reaching
conn.close(), leaking a sqlite connection each time a preset named a GPU the
database does not hold.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(data): drop the 23 presets whose GPU has no WebGL data

A preset records only its GPU's name. The WebGL parameters, extensions and
shader precision behind it have to come from somewhere, and for these 23
nothing Camoufox has describes the GPU: fpgen has never seen Firefox report it
on that OS. So each launch paired the name with another device's parameters,
a mismatch any WebGL fingerprinter can see. They were:

- Windows on ARM (Adreno 650);
- Direct3D 10-level GPUs (vs_4_0/vs_4_1);
- "Generic Renderer";
- 945GM and GTX 480 on macOS;
- nouveau/Mesa buckets on Linux;
- one Linux preset pairing NVIDIA's proprietary vendor string with the
  nouveau renderer name.

scripts/clean-fingerprint-data.py now applies the rule, via a shared
fingerprints.firefox_gpus(), and test_shipped_data asserts it. 374 presets
remain, and every OS keeps its presets. ROADMAP.md lists capturing WebGL data
for these GPUs, which would bring them back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(python): draw every identity's WebGL from fpgen

WebGL vendor, renderer, context attributes, extensions, parameters and
shader precisions, for WebGL1 and WebGL2, now come from fpgen's recorded
Firefox devices instead of webgl_data.db, which is deleted with the
camoufox/webgl/ package.

camoufox/webgl.py:
- webgl_for_gpu() traces `webgl` given Firefox, the OS and the GPU, then
  `webgl2` given the chosen `webgl` too, and draws each with one seeded
  random.Random. The GPU and the webgl value are pinned by their fpgen
  lookup index: a dict condition is flattened into leaves that overwrite
  each other, so only the renderer applied and Linux "Mesa" and "AMD"
  Radeon HD 3200 devices came back mixed.
- sample_webgl_for_screen() draws the GPU of a generated identity from
  fpgen's per-OS weights, filtering out software rasterisers, GPUs the OS
  cannot report, discrete GPUs behind a netbook screen and the
  resistFingerprinting "Mozilla" mask before the weighted choice, so there
  is no rejection loop. An empty pool raises.
- The draft/host-dependent extension filter moves over unchanged.

A preset's GPU and a caller's webgl_config pair are looked up as given;
a pair fpgen has never seen from Firefox on that OS raises instead of
falling back to another GPU. generate_context_fingerprint no longer
falls back to the host GPU when the draw fails.

For 10 of the 15 (GPU, OS) pairs the two sources share, one of fpgen's
records converts to exactly the database row on every value the browser
reads. The other five rows (Linux R9 200 and Radeon HD 3200, macOS
Intel HD, and two software rasterisers) are devices fpgen does not carry;
those GPUs now present fpgen's recorded devices instead. The Linux
GTX 980 row is kept as a test fixture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: say where WebGL comes from now that the database is gone

The per-context guide, the fpgen.yml header and coherence's comments still
named webgl_data.db and sample_webgl(). They now point at camoufox/webgl.py
and fpgen. The guide also claimed presets carry WebGL parameters; they
record only the vendor and renderer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(patches): a host's missing speech daemon no longer errors spoofed speech

On a Linux host where speech-dispatcher cannot start, Firefox broadcasts
synth-voices-error, and SpeechSynthesis answers it by firing `error` on every
queued utterance. So a spoofed Windows voice errored about 11ms into speak()
on any host without the daemon: the CI runners, and most servers. It passed
only where the daemon runs.

While Camoufox manages the voice list, the registry no longer forwards a host
backend's error. The spoofed voices do not depend on the host's engine, and a
Windows or macOS identity never raises one. The guard now makes the daemon
unreachable itself, so it tests this case on every machine; on the previous
build it fails every time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ci: stop skipping the two click tests that stock animation timing fixed

test_wait_for_stable_position and test_timeout_waiting_for_stable_position
were skipped with humanized travel time as the reason. The real cause was
instant animations. Every finite animation finished at once, so the button
Playwright waits on to stop moving never moved, and the click landed where
upstream does not expect. With animations on stock timing both pass, and the
skiplist audit flagged them as no longer failing. The entries go, and the
counts in ci/README.md drop from 14 to 12.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(build-tester): accept 18 and 22 cores, as real hardware reports

plausibleHWC's list of common core counts lacked 18 and 22 -- Intel Meteor
Lake laptops (Core Ultra 5 125H, Core Ultra 7 155H), and 22 is in 8 recorded
presets. build-tester draws random presets, so a run that picked one of the two
Linux presets reporting 22 failed: about one run in eleven, on any pull
request. A CI self-test now fails if the list rejects any core count pythonlib
can present (the presets and PLAUSIBLE_CORE_COUNTS).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(guards): judge the query-cost probes on a median, not one sample

stock-parity-probes timed each getter once. On a shared runner one GC pause or
CPU-steal spike decided the verdict: navigator.hardwareConcurrency took 77 ms
against a 50 ms allowance on the same restored build that passed the run
before. Each pair is now timed five times, interleaved, and compared by median.
The regressions these catch (a sync IPC per read, ~240 ms over the loop) cost
extra on every read, so they move the median; verified by giving the getter a
constant ~4 us of extra work per read -- 86 ms median, FAIL -- while the
healthy build passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(native): compare the whole fingerprint when two launches must differ

test_two_browsers_get_different_fingerprints compared seven coarse values:
UA, platform, screen size, core count, timezone and language. CI pins the
timezone and language, and real machines share the rest: two draws of a
common Mac (Firefox 152, MacIntel, 2560x1440, 8 cores) matched, and the test
failed on a correct browser.

It now reads the whole fingerprint a site computes, from a script in the page:
- navigator values, screen and window geometry, device pixel ratio, timezone;
- WebGL vendor, renderer, limits and extensions;
- installed fonts, measured by width against the generic fallbacks;
- voices, media-device counts, and an OfflineAudioContext hash.
The page is served from an https URL Playwright fulfils locally, because
mediaDevices exists only in a secure context. The page computes the result
itself because the isolated world may not read audio sample data. The test
then requires the fingerprints to differ, and the audio hash to differ on its
own, since its noise is seeded per identity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Update README to remove warning, camoufox is now actively maintained

Camoufox will now be actively maintained and improved for the foreseeable future

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jake Writer
2026-09-26 20:13:35 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent c769df8ea8
commit 180e6553cc
151 changed files with 3767 additions and 9498 deletions
@@ -0,0 +1,567 @@
{
"webGl:renderer": "NVIDIA GeForce GTX 980, or similar",
"webGl:vendor": "NVIDIA Corporation",
"webGl:contextAttributes": {
"alpha": true,
"antialias": true,
"depth": true,
"failIfMajorPerformanceCaveat": false,
"powerPreference": "default",
"premultipliedAlpha": true,
"preserveDrawingBuffer": false,
"stencil": false
},
"webGl:supportedExtensions": [
"ANGLE_instanced_arrays",
"EXT_blend_minmax",
"EXT_color_buffer_half_float",
"EXT_depth_clamp",
"EXT_float_blend",
"EXT_frag_depth",
"EXT_shader_texture_lod",
"EXT_sRGB",
"EXT_texture_compression_bptc",
"EXT_texture_compression_rgtc",
"EXT_texture_filter_anisotropic",
"OES_element_index_uint",
"OES_fbo_render_mipmap",
"OES_standard_derivatives",
"OES_texture_float",
"OES_texture_float_linear",
"OES_texture_half_float",
"OES_texture_half_float_linear",
"OES_vertex_array_object",
"WEBGL_color_buffer_float",
"WEBGL_compressed_texture_etc",
"WEBGL_compressed_texture_s3tc",
"WEBGL_compressed_texture_s3tc_srgb",
"WEBGL_debug_renderer_info",
"WEBGL_debug_shaders",
"WEBGL_depth_texture",
"WEBGL_draw_buffers",
"WEBGL_lose_context"
],
"webGl:parameters": {
"2849": 1,
"2884": false,
"2885": 1029,
"2886": 2305,
"2928": [
0,
1
],
"2929": false,
"2930": true,
"2931": 1,
"2932": 513,
"2960": false,
"2961": 0,
"2962": 519,
"2963": 4294967295,
"2964": 7680,
"2965": 7680,
"2966": 7680,
"2967": 0,
"2968": 4294967295,
"2978": [
0,
0,
300,
150
],
"3024": true,
"3042": false,
"3074": null,
"3088": [
0,
0,
300,
150
],
"3089": false,
"3106": [
0,
0,
0,
0
],
"3107": [
true,
true,
true,
true
],
"3314": null,
"3315": null,
"3316": null,
"3317": 4,
"3330": null,
"3331": null,
"3332": null,
"3333": 4,
"3379": 32768,
"3386": [
32768,
32768
],
"3408": 8,
"3410": 8,
"3411": 8,
"3412": 8,
"3413": 8,
"3414": 24,
"3415": 0,
"7936": "Mozilla",
"7937": "NVIDIA GeForce GTX 980, or similar",
"7938": "WebGL 1.0",
"10752": 0,
"32773": [
0,
0,
0,
0
],
"32777": 32774,
"32823": false,
"32824": 0,
"32873": null,
"32877": null,
"32878": null,
"32883": null,
"32926": false,
"32928": false,
"32936": 1,
"32937": 4,
"32938": 1,
"32939": false,
"32968": 0,
"32969": 1,
"32970": 0,
"32971": 1,
"33000": null,
"33001": null,
"33170": 4352,
"33901": [
1,
2047
],
"33902": [
1,
10
],
"34016": 33984,
"34024": 32768,
"34045": null,
"34047": null,
"34068": null,
"34076": 32768,
"34467": null,
"34816": 519,
"34817": 7680,
"34818": 7680,
"34819": 7680,
"34852": null,
"34853": null,
"34854": null,
"34855": null,
"34856": null,
"34857": null,
"34858": null,
"34859": null,
"34860": null,
"34877": 32774,
"34921": 16,
"34930": 32,
"34964": null,
"34965": null,
"35071": null,
"35076": null,
"35077": null,
"35371": null,
"35373": null,
"35374": null,
"35375": null,
"35376": null,
"35377": null,
"35379": null,
"35380": null,
"35657": null,
"35658": null,
"35659": null,
"35660": 32,
"35661": 192,
"35723": null,
"35724": "WebGL GLSL ES 1.0",
"35725": null,
"35738": 5121,
"35739": 6408,
"35968": null,
"35977": null,
"35978": null,
"35979": null,
"36003": 0,
"36004": 4294967295,
"36005": 4294967295,
"36006": null,
"36007": null,
"36063": null,
"36183": null,
"36203": null,
"36345": null,
"36347": 1024,
"36348": 32,
"36349": 1024,
"36387": null,
"36388": null,
"36392": null,
"36795": null,
"37137": null,
"37154": null,
"37157": null,
"37440": false,
"37441": false,
"37443": 37444,
"37444": null,
"37445": "NVIDIA Corporation",
"37446": "NVIDIA GeForce GTX 980, or similar",
"37447": null,
"38449": null
},
"webGl:shaderPrecisionFormats": {
"35633,36336": {
"rangeMin": 127,
"rangeMax": 127,
"precision": 23
},
"35633,36337": {
"rangeMin": 127,
"rangeMax": 127,
"precision": 23
},
"35633,36338": {
"rangeMin": 127,
"rangeMax": 127,
"precision": 23
},
"35633,36339": {
"rangeMin": 24,
"rangeMax": 24,
"precision": 0
},
"35633,36340": {
"rangeMin": 24,
"rangeMax": 24,
"precision": 0
},
"35633,36341": {
"rangeMin": 24,
"rangeMax": 24,
"precision": 0
},
"35632,36336": {
"rangeMin": 127,
"rangeMax": 127,
"precision": 23
},
"35632,36337": {
"rangeMin": 127,
"rangeMax": 127,
"precision": 23
},
"35632,36338": {
"rangeMin": 127,
"rangeMax": 127,
"precision": 23
},
"35632,36339": {
"rangeMin": 24,
"rangeMax": 24,
"precision": 0
},
"35632,36340": {
"rangeMin": 24,
"rangeMax": 24,
"precision": 0
},
"35632,36341": {
"rangeMin": 24,
"rangeMax": 24,
"precision": 0
}
},
"webGl2:contextAttributes": {
"alpha": true,
"antialias": true,
"depth": true,
"failIfMajorPerformanceCaveat": false,
"powerPreference": "default",
"premultipliedAlpha": true,
"preserveDrawingBuffer": false,
"stencil": false
},
"webGl2:supportedExtensions": [
"EXT_color_buffer_float",
"EXT_depth_clamp",
"EXT_float_blend",
"EXT_texture_compression_bptc",
"EXT_texture_compression_rgtc",
"EXT_texture_filter_anisotropic",
"OES_draw_buffers_indexed",
"OES_texture_float_linear",
"WEBGL_compressed_texture_etc",
"WEBGL_compressed_texture_s3tc",
"WEBGL_compressed_texture_s3tc_srgb",
"WEBGL_debug_renderer_info",
"WEBGL_debug_shaders",
"WEBGL_lose_context"
],
"webGl2:parameters": {
"2849": 1,
"2884": false,
"2885": 1029,
"2886": 2305,
"2928": [
0,
1
],
"2929": false,
"2930": true,
"2931": 1,
"2932": 513,
"2960": false,
"2961": 0,
"2962": 519,
"2963": 4294967295,
"2964": 7680,
"2965": 7680,
"2966": 7680,
"2967": 0,
"2968": 4294967295,
"2978": [
0,
0,
300,
150
],
"3024": true,
"3042": false,
"3074": 1029,
"3088": [
0,
0,
300,
150
],
"3089": false,
"3106": [
0,
0,
0,
0
],
"3107": [
true,
true,
true,
true
],
"3314": 0,
"3315": 0,
"3316": 0,
"3317": 4,
"3330": 0,
"3331": 0,
"3332": 0,
"3333": 4,
"3379": 32768,
"3386": [
32768,
32768
],
"3408": 8,
"3410": 8,
"3411": 8,
"3412": 8,
"3413": 8,
"3414": 24,
"3415": 0,
"7936": "Mozilla",
"7937": "NVIDIA GeForce GTX 980, or similar",
"7938": "WebGL 2.0",
"10752": 0,
"32773": [
0,
0,
0,
0
],
"32777": 32774,
"32823": false,
"32824": 0,
"32873": null,
"32877": 0,
"32878": 0,
"32883": 16384,
"32926": false,
"32928": false,
"32936": 1,
"32937": 4,
"32938": 1,
"32939": false,
"32968": 0,
"32969": 1,
"32970": 0,
"32971": 1,
"33000": 1048576,
"33001": 1048576,
"33170": 4352,
"33901": [
1,
2047
],
"33902": [
1,
1
],
"34016": 33984,
"34024": 32768,
"34045": 15,
"34047": null,
"34068": null,
"34076": 32768,
"34467": null,
"34816": 519,
"34817": 7680,
"34818": 7680,
"34819": 7680,
"34852": 8,
"34853": 1029,
"34854": 0,
"34855": 0,
"34856": 0,
"34857": 0,
"34858": 0,
"34859": 0,
"34860": 0,
"34877": 32774,
"34921": 16,
"34930": 32,
"34964": null,
"34965": null,
"35071": 2048,
"35076": -8,
"35077": 7,
"35371": 14,
"35373": 14,
"35374": 84,
"35375": 84,
"35376": 65536,
"35377": 233472,
"35379": 233472,
"35380": 256,
"35657": 4096,
"35658": 4096,
"35659": 124,
"35660": 32,
"35661": 192,
"35723": 4352,
"35724": "WebGL GLSL ES 3.00",
"35725": null,
"35738": 5121,
"35739": 6408,
"35968": 4,
"35977": false,
"35978": 128,
"35979": 4,
"36003": 0,
"36004": 4294967295,
"36005": 4294967295,
"36006": null,
"36007": null,
"36063": 8,
"36183": 32,
"36203": 4294967295,
"36345": null,
"36347": 1024,
"36348": 32,
"36349": 1024,
"36387": false,
"36388": false,
"36392": null,
"36795": null,
"37137": 1.8446744073709552e+19,
"37154": 128,
"37157": 128,
"37440": false,
"37441": false,
"37443": 37444,
"37444": null,
"37445": "NVIDIA Corporation",
"37446": "NVIDIA GeForce GTX 980, or similar",
"37447": 1000000000,
"38449": null
},
"webGl2:shaderPrecisionFormats": {
"35633,36336": {
"rangeMin": 127,
"rangeMax": 127,
"precision": 23
},
"35633,36337": {
"rangeMin": 127,
"rangeMax": 127,
"precision": 23
},
"35633,36338": {
"rangeMin": 127,
"rangeMax": 127,
"precision": 23
},
"35633,36339": {
"rangeMin": 24,
"rangeMax": 24,
"precision": 0
},
"35633,36340": {
"rangeMin": 24,
"rangeMax": 24,
"precision": 0
},
"35633,36341": {
"rangeMin": 24,
"rangeMax": 24,
"precision": 0
},
"35632,36336": {
"rangeMin": 127,
"rangeMax": 127,
"precision": 23
},
"35632,36337": {
"rangeMin": 127,
"rangeMax": 127,
"precision": 23
},
"35632,36338": {
"rangeMin": 127,
"rangeMax": 127,
"precision": 23
},
"35632,36339": {
"rangeMin": 24,
"rangeMax": 24,
"precision": 0
},
"35632,36340": {
"rangeMin": 24,
"rangeMax": 24,
"precision": 0
},
"35632,36341": {
"rangeMin": 24,
"rangeMax": 24,
"precision": 0
}
},
"webGl2Enabled": true
}
+25
View File
@@ -0,0 +1,25 @@
"""`camoufox list --path` shows install paths in both listing modes."""
from pathlib import Path
from click.testing import CliRunner
from camoufox import __main__ as cli
from camoufox.multiversion import InstalledVersion
from camoufox.pkgman import Version
def test_list_all_shows_the_path_of_an_installed_build(monkeypatch):
install = Path("/cache/browsers/official/152.0.4-beta.30")
installed = InstalledVersion(
repo_name="official", version=Version(build="beta.30", version="152.0.4"), path=install
)
cache = {"repos": [{"name": "official", "versions": [{"version": "152.0.4", "build": "beta.30"}]}]}
monkeypatch.setattr(cli, "_ensure_synced", lambda: True)
monkeypatch.setattr(cli, "load_repo_cache", lambda: cache)
monkeypatch.setattr(cli, "list_installed", lambda: [installed])
result = CliRunner().invoke(cli.cli, ["list", "all", "--path"])
assert result.exit_code == 0, result.output
assert str(install) in result.output
+37 -2
View File
@@ -1,7 +1,7 @@
"""Every identity Camoufox can produce has to be a machine that could exist.
The pools are sampled independently -- navigator and screen from fpgen, the GPU
from webgl_data.db, fonts and voices from their own catalogues -- so an
from fpgen's WebGL records, fonts and voices from their own catalogues -- so an
incoherent identity is assembled rather than inherited, and cleaning the pools
cannot prevent it. These tests run the assembled identity, from every source, past
camoufox.coherence.
@@ -36,7 +36,8 @@ class TestRules:
assert config["navigator.hardwareConcurrency"] == 8
def test_a_mac_cannot_report_a_braswell_atom_igp(self):
# webgl_data.db weights this at 7.4% of the macOS pool.
# The retired WebGL database weighted this at 7.4% of the macOS pool,
# and fpgen records it from macOS too.
config = {"webGl:renderer": "Intel(R) HD Graphics 400, or similar"}
assert [v.rule for v in coherence.validate(config, "mac")] == ["gpu-matches-os"]
@@ -149,3 +150,37 @@ class TestEveryIdentityIsCoherent:
for i, preset in enumerate(fp.load_presets("150")["presets"][os_name]):
config = launch(os=os_name, fingerprint_preset=preset)
assert coherence.validate(config, get_target_os(config)) == [], (os_name, i)
_MIDPOINT_REPAIRS = """
from camoufox import coherence
out = []
for os_key, steps in sorted(coherence.PLAUSIBLE_DPR.items()):
steps = sorted(steps)
for low, high in zip(steps, steps[1:]):
config = {"window.devicePixelRatio": (low + high) / 2}
coherence.apply(config, os_key)
out.append(config["window.devicePixelRatio"])
print(out)
"""
def test_a_midpoint_repairs_to_the_lower_step_whether_or_not_bytecode_is_cached(tmp_path):
"""The steps were frozensets, and min() keeps the first of equal distances.
A frozenset literal iterates in one order when compiled from source and in
another when loaded back from a .pyc, so the same identity repaired
differently on its first launch than on later ones."""
import subprocess
import sys
from pathlib import Path
env = {"PYTHONPYCACHEPREFIX": str(tmp_path), "PYTHONPATH": str(Path(coherence.__file__).parents[1])}
runs = [
subprocess.run([sys.executable, "-c", _MIDPOINT_REPAIRS], env=env, capture_output=True,
text=True, check=True).stdout
for _ in range(2) # the first compiles and writes the .pyc, the second loads it
]
assert runs[0] == runs[1]
lower = [low for _, steps in sorted(coherence.PLAUSIBLE_DPR.items())
for low in sorted(steps)[:-1]]
assert runs[0].strip() == str(lower)
+40
View File
@@ -38,6 +38,18 @@ MASKCONFIG_READ = re.compile(r'MaskConfig::(?:Get|Has)\w*\(\s*"([^"]+)"')
# Add here (with a reason) only when the read genuinely cannot name its key.
ALLOWED_UNDECLARED: set = set()
# Declared keys the browser never reads, each with the reason it is declared
# anyway. Everything else in properties.json must be read by a patch or by
# Juggler: a key nothing reads does nothing, silently.
NOT_READ_BY_THE_BROWSER = {
"locale:script": "the launcher joins it with locale:language/region into the UI locale",
"navigator.doNotTrack": "the launcher applies it as privacy.donottrackheader.enabled (#760)",
"navigator.buildID": "declared ahead of the patch that reads it (#780)",
}
# How Juggler and the patches name a key: a quoted string literal.
QUOTED = '"{key}"', "'{key}'"
def _sources():
for pattern in ("patches/**/*.patch", "additions/**/*"):
@@ -97,6 +109,34 @@ def test_every_key_the_browser_reads_is_declared():
pytest.fail("\n".join(lines))
def test_every_declared_key_is_read_by_the_browser():
"""The other direction: canvas:seed (#721) was declared, generated by both
launchers and sent on every launch for three releases after the patch that
read it was removed (#528)."""
text = "".join(path.read_text(errors="ignore") for path in _sources())
unread = sorted(
key
for key in _declared_keys()
if key not in NOT_READ_BY_THE_BROWSER
and not any(form.format(key=key) in text for form in QUOTED)
)
assert not unread, (
"settings/properties.json declares keys that no patch or Juggler file "
f"reads, so setting them does nothing: {unread}. Remove them, or add them "
"to NOT_READ_BY_THE_BROWSER with the reason they are declared."
)
def test_keys_not_read_by_the_browser_are_really_unread():
"""An exemption must lapse once the browser starts reading the key."""
text = "".join(path.read_text(errors="ignore") for path in _sources())
now_read = [
key for key in NOT_READ_BY_THE_BROWSER
if any(form.format(key=key) in text for form in QUOTED)
]
assert not now_read, f"remove from NOT_READ_BY_THE_BROWSER, the browser reads them now: {now_read}"
@pytest.mark.parametrize("key", ["media:spoof_codecs"])
def test_known_previously_missing_keys_stay_declared(key):
"""Pin the specific keys this guard was written for, so a schema edit that
+82
View File
@@ -0,0 +1,82 @@
"""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 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"),
],
)
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"]
+48 -19
View File
@@ -42,7 +42,7 @@ def launch(**kwargs):
return config_of(utils.launch_options(**kwargs))
DRAWN = ("canvas:seed", "audio:seed", "fonts", "voices", "webGl:renderer")
DRAWN = ("audio:seed", "fonts", "voices", "webGl:renderer")
def drawn(config):
@@ -51,7 +51,7 @@ def drawn(config):
class TestUnpinnedLaunchesAreDistinct:
def test_noise_seeds_do_not_collide(self):
seeds = [launch()["canvas:seed"] for _ in range(40)]
seeds = [launch()["audio:seed"] for _ in range(40)]
# 40 draws from 2**32: any collision means the seed space collapsed.
assert len(set(seeds)) == len(seeds)
@@ -77,33 +77,31 @@ class TestPinnedIdentityIsStable:
pytest.skip("no presets bundled")
first = launch(os="windows", fingerprint_preset=preset)
second = launch(os="windows", fingerprint_preset=preset)
assert (first["canvas:seed"], first["audio:seed"]) == (second["canvas:seed"], second["audio:seed"])
assert first["audio:seed"] == second["audio:seed"]
assert first["fonts"] == second["fonts"]
@pytest.mark.parametrize("os_name", ["windows", "macos", "linux"])
def test_every_bundled_preset_launches(self, os_name):
# The test above draws ONE preset at random, so a preset that cannot
# launch shows up as a 1-in-11 flake rather than a failure -- which is
# how it reached CI. 39 of the 435 bundled presets name a GPU that is
# not among the 33 in webgl_data.db, and sample_webgl raises for those.
# Every preset has to produce launch options; see the fallback in
# utils.launch_options.
from camoufox.webgl import sample_webgl
# launch would show up as a flake rather than a failure. Every preset
# must launch with its own GPU and that GPU's recorded parameters.
presets = fp.load_presets("150")["presets"][os_name]
key = {"windows": "win", "macos": "mac", "linux": "lin"}[os_name]
for i, preset in enumerate(presets):
config = launch(os=os_name, fingerprint_preset=preset)
# Whatever GPU survives, the renderer the page reads and the
# parameters behind it must come from the SAME recorded device --
# merge_into does not overwrite, so a fallback that forgets to drop
# the preset's pair leaves one device's name on another's data.
assert config["webGl:renderer"] == preset["webgl"]["unmaskedRenderer"], (os_name, i)
assert config.get("webGl:parameters"), (os_name, i)
sample_webgl(key, config["webGl:vendor"], config["webGl:renderer"])
def test_caller_seeds_are_kept(self):
config = launch(config={"canvas:seed": 7, "audio:seed": 9})
assert (config["canvas:seed"], config["audio:seed"]) == (7, 9)
def test_caller_seed_is_kept(self):
assert launch(config={"audio:seed": 9})["audio:seed"] == 9
def test_no_canvas_seed_is_generated():
"""The browser adds no canvas noise (#528), and no patch reads canvas:seed
(#721). Generating one only sent the browser a value it ignored."""
assert "canvas:seed" not in launch()
context = fp.generate_context_fingerprint(os="linux")
assert "canvas:seed" not in context["config"]
assert "setCanvasSeed" not in context["init_script"]
def test_salt_of_equal_objects_is_equal(self):
a = fp.generate_fingerprint(os="windows")
@@ -162,3 +160,34 @@ class TestPrefsEnvIsAscii:
joined = "".join(env[f"CAMOU_PREFS_{i}"] for i in range(1, len(env) + 1))
assert joined.isascii()
assert orjson.loads(joined) == prefs
@pytest.mark.parametrize("off", [None, False])
def test_fingerprint_preset_off_never_draws_a_preset(off):
"""`fingerprint_preset=False` means off, the same as None. It used to be
checked with `is not None`, so False drew a random bundled preset."""
with mock.patch.object(utils, "get_random_preset", side_effect=AssertionError("preset drawn")):
launch(fingerprint_preset=off)
def test_no_glyph_spacing_seed_is_generated():
"""Glyph-spacing noise moved every measured text width off what the same
font gives on a real machine, so it was itself a fingerprint; the feature
is gone from the browser, and the launcher sends nothing for it."""
assert "fonts:spacing_seed" not in launch()
context = fp.generate_context_fingerprint(os="linux")
assert "fonts:spacing_seed" not in context["config"]
assert "setFontSpacingSeed" not in context["init_script"]
def test_config_overrides_reach_the_config_and_the_init_script():
context = fp.generate_context_fingerprint(os="linux", config_overrides={"audio:seed": 7})
assert context["config"]["audio:seed"] == 7
assert "setAudioFingerprintSeed(7)" in context["init_script"]
def test_instant_animations_warn_that_they_are_detectable():
from camoufox._warnings import LeakWarning
with pytest.warns(LeakWarning, match="getComputedTiming"):
launch(config={"instantAnimations": True}, i_know_what_im_doing=False)
+2 -2
View File
@@ -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)
@@ -0,0 +1,36 @@
"""A context's user agent carries the Firefox version of the browser it runs in.
Without an explicit ff_version, NewContext kept whatever version fpgen drew
(e.g. Firefox/146) on a 160 browser, so the UA disagreed with every
version-dependent API the page could probe.
"""
import asyncio
import re
from unittest import mock
import pytest
from camoufox import async_api, sync_api
def _user_agent(init_script):
return re.search(r'setNavigatorUserAgent\("([^"]+)"\)', init_script).group(1)
@pytest.mark.parametrize("api", ["sync", "async"])
def test_user_agent_matches_the_browser_version(api):
context = mock.MagicMock()
browser = mock.MagicMock()
# Playwright's Browser.version for Firefox: MOZ_APP_VERSION_DISPLAY.
browser.version = "160.0.1"
if api == "sync":
browser.new_context.return_value = context
sync_api.NewContext(browser, os="linux")
else:
context.add_init_script = mock.AsyncMock()
browser.new_context = mock.AsyncMock(return_value=context)
asyncio.run(async_api.AsyncNewContext(browser, os="linux"))
user_agent = _user_agent(context.add_init_script.call_args.args[0])
assert "Firefox/160.0" in user_agent and "rv:160.0" in user_agent, user_agent
+96
View File
@@ -0,0 +1,96 @@
"""NewContext derives the WebRTC IP and timezone from the proxy's exit IP.
Two defects, both of which left a context with the host's WebRTC IP and
timezone while its traffic went through the proxy:
- The lookup built its own proxy URL with urlparse, which reads a scheme-less
server such as "1.2.3.4:8080" (a form Playwright accepts) as scheme "1.2.3.4"
and drops the host.
- A failed lookup was swallowed, and the context launched without the values.
"""
import asyncio
from unittest import mock
import pytest
from camoufox import async_api, ip, sync_api
from camoufox.exceptions import InvalidIP
class _Response:
def __init__(self, payload):
self._payload = payload
def raise_for_status(self):
pass
def json(self):
return self._payload
EXIT = {"status": "success", "query": "203.0.113.7", "timezone": "Europe/Paris"}
def _sync_browser():
browser = mock.MagicMock()
browser.version = "152.0.4"
return browser
def _async_browser():
context = mock.MagicMock()
context.add_init_script = mock.AsyncMock()
browser = mock.MagicMock()
browser.version = "152.0.4"
browser.new_context = mock.AsyncMock(return_value=context)
return browser
def _new_context(api, proxy, **kwargs):
if api == "sync":
browser = _sync_browser()
sync_api.NewContext(browser, os="linux", proxy=proxy, **kwargs)
return browser.new_context.call_args.kwargs, browser.new_context.return_value
browser = _async_browser()
asyncio.run(async_api.AsyncNewContext(browser, os="linux", proxy=proxy, **kwargs))
return browser.new_context.call_args.kwargs, browser.new_context.return_value
@pytest.mark.parametrize("api", ["sync", "async"])
@pytest.mark.parametrize(
"server, expected",
[
("1.2.3.4:8080", "http://u:p@1.2.3.4:8080"),
("proxy.example.com:8080", "http://u:p@proxy.example.com:8080"),
("http://proxy.example.com:8080", "http://u:p@proxy.example.com:8080"),
("socks5://proxy.example.com:1080", "socks5://u:p@proxy.example.com:1080"),
],
)
def test_lookup_goes_through_the_proxy_with_its_credentials(api, server, expected):
with mock.patch.object(ip.requests, "get", return_value=_Response(EXIT)) as get:
options, context = _new_context(api, {"server": server, "username": "u", "password": "p"})
assert get.call_args.kwargs["proxies"] == {"http": expected, "https": expected}
assert options["timezone_id"] == "Europe/Paris"
assert "203.0.113.7" in context.add_init_script.call_args.args[0]
@pytest.mark.parametrize("api", ["sync", "async"])
@pytest.mark.parametrize(
"failure",
[
ip.requests.ConnectionError("proxy refused"),
_Response({"status": "fail", "message": "private range"}),
],
)
def test_a_failed_lookup_raises_instead_of_launching_without_the_values(api, failure):
get = mock.Mock(side_effect=failure) if isinstance(failure, Exception) else mock.Mock(return_value=failure)
with mock.patch.object(ip.requests, "get", get), pytest.raises(InvalidIP, match="webrtc_ip"):
_new_context(api, {"server": "1.2.3.4:8080"})
@pytest.mark.parametrize("api", ["sync", "async"])
def test_no_lookup_when_both_values_are_given(api):
with mock.patch.object(ip.requests, "get") as get:
_new_context(api, {"server": "1.2.3.4:8080"}, webrtc_ip="198.51.100.1", timezone_id="UTC")
get.assert_not_called()
@@ -0,0 +1,16 @@
"""No identity pins the page's scroll offset.
The browser returns a configured screen.pageYOffset from scrollY on every read,
so a drawn value froze the page at one scroll position whatever the user did.
"""
from camoufox.fingerprints import from_fpgen, generate_fingerprint
_SCROLL_KEYS = ("screen.pageXOffset", "screen.pageYOffset")
def test_a_drawn_scroll_offset_is_not_carried_into_the_config():
fingerprint = generate_fingerprint(os="windows")
fingerprint["window"] = {**fingerprint["window"], "pageXOffset": 17, "pageYOffset": 528}
config = from_fpgen(fingerprint, "152")
assert not set(_SCROLL_KEYS) & config.keys()
+76
View File
@@ -275,3 +275,79 @@ def test_launch_server_surfaces_child_exit_instead_of_pipe_error(monkeypatch, tm
server.launch_server()
assert "3" in str(excinfo.value), str(excinfo.value)
class _FakeVirtualDisplay:
instances = []
def __init__(self, debug=None):
self.debug = debug
self.killed = False
_FakeVirtualDisplay.instances.append(self)
def get(self):
return ":99"
def kill(self):
self.killed = True
class _ExitedProcess:
def __init__(self):
self.stdin = open(os.devnull, "w")
self.returncode = 0
def poll(self):
return self.returncode
def wait(self, timeout=None):
return self.returncode
@pytest.fixture
def fake_virtual_display(monkeypatch):
_FakeVirtualDisplay.instances = []
monkeypatch.setattr(server, "VirtualDisplay", _FakeVirtualDisplay, raising=False)
monkeypatch.setattr(server, "get_nodejs", lambda: "/node")
return _FakeVirtualDisplay.instances
def test_launch_server_runs_virtual_headless_on_a_virtual_display(
monkeypatch, fake_virtual_display
):
# headless='virtual' is a Camoufox() option, not a Playwright one: the
# server must start Xvfb, launch headful on it, and kill it when the
# server process exits.
launched = {}
def fake_launch_options(**kwargs):
launched.update(kwargs)
return {}
monkeypatch.setattr(server, "launch_options", fake_launch_options)
monkeypatch.setattr(
server.subprocess, "Popen", lambda *args, **kwargs: _ExitedProcess()
)
with pytest.raises(RuntimeError):
server.launch_server(headless="virtual")
assert len(fake_virtual_display) == 1
assert launched["headless"] is False
assert launched["virtual_display"] == ":99"
assert fake_virtual_display[0].killed
def test_launch_server_kills_virtual_display_when_launch_fails(
monkeypatch, fake_virtual_display
):
def failing_launch_options(**kwargs):
raise ValueError("invalid options")
monkeypatch.setattr(server, "launch_options", failing_launch_options)
with pytest.raises(ValueError, match="invalid options"):
server.launch_server(headless="virtual")
assert len(fake_virtual_display) == 1
assert fake_virtual_display[0].killed
+16 -30
View File
@@ -9,11 +9,10 @@ rules -- `scripts/clean-fingerprint-data.py --write` is what makes these pass.
Dropped on 2026-09-17: 38 of 435 presets (26 with a GPU their OS cannot report,
7 pairing Apple Silicon with a core count Apple never shipped, 4 with a colour
depth their GPU contradicts, 3 with a phone viewport, 1 claiming 40 touch
points), and 2 impossible macOS weights in webgl_data.db.
points).
"""
import json
import sqlite3
import sys
from os.path import dirname, join
from pathlib import Path
@@ -24,7 +23,6 @@ sys.path.insert(0, join(dirname(__file__), ".."))
from camoufox import coherence # noqa: E402
from camoufox.fingerprints import from_preset # noqa: E402
from camoufox.webgl.sample import DB_PATH # noqa: E402
DATA = Path(__file__).parent.parent / "camoufox"
PRESET_FILES = ("fingerprint-presets.json", "fingerprint-presets-v150.json")
@@ -46,31 +44,19 @@ def test_every_bundled_preset_is_coherent_as_stored(filename):
)
def test_no_gpu_is_offered_to_an_os_that_cannot_report_it():
connection = sqlite3.connect(DB_PATH)
try:
rows = connection.execute(
"SELECT vendor, renderer, win, mac, lin FROM webgl_fingerprints"
).fetchall()
finally:
connection.close()
assert rows, "webgl_data.db is empty"
for vendor, renderer, *weights in rows:
for os_key, weight in zip(("win", "mac", "lin"), weights):
if weight and weight > 0:
assert coherence.gpu_fits_os(renderer, os_key), (
f"{renderer!r} is offered to {os_key} at {weight}"
)
@pytest.mark.parametrize("filename", PRESET_FILES)
def test_every_preset_gpu_has_webgl_data(filename):
"""A preset records only its GPU's name; the WebGL parameters behind it come
from fpgen. A GPU fpgen has never seen Firefox report on that OS has none, so
launching it would pair the name with another device's parameters."""
from camoufox.webgl import firefox_gpus
def test_each_os_still_has_gpus_to_draw_from():
"""The filter must not empty a pool -- a single GPU per OS is its own tell."""
connection = sqlite3.connect(DB_PATH)
try:
for os_key in ("win", "mac", "lin"):
count = connection.execute(
f"SELECT COUNT(*) FROM webgl_fingerprints WHERE {os_key} > 0" # nosec
).fetchone()[0]
assert count >= 2, f"{os_key} has {count} GPU(s) left"
finally:
connection.close()
presets = json.loads((DATA / filename).read_text())["presets"]
for os_name, entries in presets.items():
known = firefox_gpus(os_name)
for index, preset in enumerate(entries):
gpu = (preset["webgl"]["unmaskedVendor"], preset["webgl"]["unmaskedRenderer"])
assert gpu in known, (
f"{filename} {os_name}[{index}]: {gpu[1]!r} has no WebGL data"
" -- run scripts/clean-fingerprint-data.py --write"
)
-1
View File
@@ -22,7 +22,6 @@ def _opts(config_blob: str):
[
('{"window.outerWidth": 360}', True),
('{"window.innerHeight": 740}', True),
('{"document.body.clientWidth": 360}', True),
('{"screen.width": 360}', False),
('{"navigator.userAgent": "x"}', False),
("{}", False),
+5 -2
View File
@@ -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("voices.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"] == []
+186
View File
@@ -0,0 +1,186 @@
"""WebGL identities drawn from fpgen's recorded Firefox devices (camoufox.webgl)."""
import json
from pathlib import Path
import pytest
from test_identity_salt import host, launch
from camoufox import coherence, utils
from camoufox import fingerprints as fp
from camoufox import webgl
from camoufox.fingerprints import gpu_screen_is_plausible, is_software_renderer
from camoufox.webgl import sample_webgl_for_screen, webgl_for_gpu
SEEDS = range(300)
OSES = ("win", "mac", "lin")
_GTX_980_LINUX = ("NVIDIA Corporation", "NVIDIA GeForce GTX 980, or similar")
_BASIC_RENDER_DRIVER = (
"Google Inc. (Microsoft)",
"ANGLE (Microsoft, Microsoft Basic Render Driver Direct3D11 vs_5_0 ps_5_0), or similar",
)
# Limits WebGL1 and WebGL2 read from the same device: MAX_TEXTURE_SIZE,
# MAX_VIEWPORT_DIMS, MAX_RENDERBUFFER_SIZE, MAX_CUBE_MAP_TEXTURE_SIZE,
# MAX_VERTEX_ATTRIBS, MAX_TEXTURE_IMAGE_UNITS, MAX_VERTEX_TEXTURE_IMAGE_UNITS,
# MAX_COMBINED_TEXTURE_IMAGE_UNITS and the three uniform/varying vector limits.
_SHARED_LIMITS = ("3379", "3386", "34024", "34076", "34921", "34930", "35660", "35661", "36347", "36348", "36349")
def _as_json(value):
# JSON has one number type: 2**64 stored as an int and as a float is the
# same value to the browser's parser.
return json.loads(json.dumps(value), parse_int=float)
def test_converter_reproduces_the_recorded_device():
"""The fixture is what the retired webgl_data.db gave launch_options for
this GPU. fpgen records the same device, so everything the browser reads
must come out identical: parameters are compared where the old row had a
value, less the UNMASKED_* strings, which the browser takes from
webGl:vendor/renderer rather than the table."""
old = json.loads((Path(__file__).parent / "data" / "webgl-gtx980-linux.json").read_text())
new = webgl_for_gpu("lin", *_GTX_980_LINUX, seed=0)
assert new.keys() == old.keys()
for key in old:
if key.endswith(":parameters"):
recorded = {
pname: value
for pname, value in old[key].items()
if value is not None and pname not in ("37445", "37446")
}
assert _as_json({pname: new[key].get(pname) for pname in recorded}) == _as_json(recorded), key
else:
assert new[key] == old[key], key
def test_same_seed_same_device():
for target_os in OSES:
for seed in (0, 1, 12345):
assert sample_webgl_for_screen(target_os, 1920, 1080, seed) == sample_webgl_for_screen(
target_os, 1920, 1080, seed
)
gpu = ("AMD", "Radeon R9 200 Series, or similar")
assert webgl_for_gpu("lin", *gpu, seed=7) == webgl_for_gpu("lin", *gpu, seed=7)
def test_seed_chooses_among_a_gpus_recorded_devices():
# fpgen records several Linux R9 200 devices; one seed must not pin them all.
drawn = {json.dumps(webgl_for_gpu("lin", "AMD", "Radeon R9 200 Series, or similar", seed=s)) for s in range(40)}
assert len(drawn) > 1
@pytest.mark.parametrize("target_os", OSES)
def test_synthetic_draw_is_a_hardware_gpu_the_os_reports(target_os):
renderers = {sample_webgl_for_screen(target_os, 1920, 1080, s)["webGl:renderer"] for s in SEEDS}
for renderer in renderers:
assert not is_software_renderer(renderer), renderer
assert renderer != "Mozilla"
assert coherence.gpu_fits_os(renderer, target_os), renderer
# A single GPU per OS is its own tell.
assert len(renderers) >= 2
@pytest.mark.parametrize("target_os", OSES)
def test_netbook_screen_never_draws_a_discrete_gpu(target_os):
for seed in SEEDS:
renderer = sample_webgl_for_screen(target_os, 1024, 600, seed)["webGl:renderer"]
assert gpu_screen_is_plausible(renderer, 1024, 600), renderer
def test_no_coherent_gpu_raises(monkeypatch):
# A pool with nothing that fits is a data defect; substituting a GPU the
# filters rejected would present exactly what they exist to prevent.
only_software = tuple(r for r in webgl._trace("gpu", "lin") if is_software_renderer(r.value["renderer"]))
assert only_software
monkeypatch.setattr(webgl, "_trace", lambda *a, **kw: only_software)
with pytest.raises(ValueError, match="No recorded lin GPU"):
sample_webgl_for_screen("lin", 1920, 1080, seed=0)
@pytest.mark.parametrize("target_os", OSES)
def test_webgl2_comes_from_the_same_device_as_webgl1(target_os):
# webgl2 is pinned to the drawn webgl; drawn on the GPU alone, a Linux
# Intel identity paired MAX_TEXTURE_SIZE 8192 with 16384.
for seed in SEEDS:
config = sample_webgl_for_screen(target_os, 1920, 1080, seed)
for pname in _SHARED_LIMITS:
assert config["webGl:parameters"][pname] == config["webGl2:parameters"][pname], (seed, pname)
def test_gpu_is_pinned_by_vendor_and_renderer():
# "Mesa" and "AMD" both report this renderer on Linux. A dict condition on
# fpgen matches the renderer alone and mixed their devices.
for seed in range(40):
assert webgl_for_gpu("lin", "Mesa", "Radeon HD 3200 Graphics, or similar", seed)["webGl:vendor"] == "Mesa"
def test_device_without_webgl2():
config = webgl_for_gpu("win", *_BASIC_RENDER_DRIVER, seed=0)
assert config["webGl2Enabled"] is False
assert not any(key.startswith("webGl2:") for key in config)
with host():
options = utils.launch_options(
os="windows", webgl_config=_BASIC_RENDER_DRIVER, headless=True, i_know_what_im_doing=True
)
assert options["firefox_user_prefs"]["webgl.enable-webgl2"] is False
def test_unknown_webgl_config_raises():
with pytest.raises(ValueError, match="No recorded WebGL data"):
launch(os="windows", webgl_config=("Apple", "Apple M1, or similar"))
def test_preset_keeps_its_own_gpu():
preset = fp.load_presets("152")["presets"]["linux"][0]
gpu = (preset["webgl"]["unmaskedVendor"], preset["webgl"]["unmaskedRenderer"])
config = launch(os="linux", fingerprint_preset=preset)
assert (config["webGl:vendor"], config["webGl:renderer"]) == gpu
pinned = (webgl._pin("gpu", {"vendor": gpu[0], "renderer": gpu[1]}),)
recorded = [webgl.to_config(r.value, [], "lin")["webGl:parameters"] for r in webgl._trace("webgl", "lin", pinned)]
assert config["webGl:parameters"] in recorded
def test_preset_gpu_fpgen_has_never_seen_raises():
preset = fp.load_presets("152")["presets"]["windows"][0]
gpu = "ANGLE (Acme, Acme GPU 9000 Direct3D11 vs_5_0 ps_5_0)"
preset = {**preset, "webgl": {"unmaskedVendor": "Google Inc. (Acme)", "unmaskedRenderer": gpu}}
with pytest.raises(ValueError, match="Acme GPU 9000"):
launch(os="windows", fingerprint_preset=preset)
# -- extensions ---------------------------------------------------------------
def _extensions(target_os, key):
return [
set(sample_webgl_for_screen(target_os, 1920, 1080, s).get(key) or ()) for s in range(100)
]
def test_windows_keeps_ovr_multiview2_on_webgl2():
assert any("OVR_multiview2" in exts for exts in _extensions("win", "webGl2:supportedExtensions"))
def test_linux_filters_ovr_multiview2():
# fpgen records it on ~20% of Linux WebGL2 devices.
assert not any("OVR_multiview2" in exts for exts in _extensions("lin", "webGl2:supportedExtensions"))
def test_draft_extensions_filtered_on_every_os():
recorded = {
"vendor": "v",
"renderer": "r",
"contextAttributes": {},
"params": {},
"shaderPrecisionFormats": [],
"supportedExtensions": ["ANGLE_instanced_arrays", "WEBGL_multi_draw", "WEBGL_compressed_texture_etc1"],
}
webgl2 = {**recorded, "supportedExtensions": ["EXT_texture_norm16", "WEBGL_clip_cull_distance", "OVR_multiview2"]}
for target_os in OSES:
config = webgl.to_config(recorded, webgl2, target_os)
assert config["webGl:supportedExtensions"] == ["ANGLE_instanced_arrays"]
assert config["webGl2:supportedExtensions"] == (["OVR_multiview2"] if target_os == "win" else [])
@@ -1,60 +0,0 @@
"""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
@@ -4,8 +4,8 @@ Tests for the WebGL <-> screen coherence helpers in camoufox.fingerprints.
Run with:
cd pythonlib && python -m pytest tests/test_webgl_screen_consistency.py -v
The regression these guard (daijro/camoufox#729): BrowserForge picks the
screen, webgl_data.db picks the GPU, and nothing ties them together -- so the
The regression these guard (daijro/camoufox#729): the generator picks the
screen, camoufox.webgl picks the GPU, and nothing ties them together -- so the
synthetic path can emit pairs no real machine ships (a discrete GPU behind a
1024x600 netbook panel).
@@ -24,15 +24,14 @@ import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from camoufox import fingerprints # noqa: E402
from camoufox.fingerprints import ( # noqa: E402
MODERN_SCREEN_FLOOR,
_renderer_bucket,
gpu_screen_is_plausible,
is_software_renderer,
raise_screen_to_modern_floor,
sample_webgl_for_screen,
)
from camoufox.webgl import sample_webgl_for_screen # noqa: E402
# The three spellings Gecko emits for one discrete-NVIDIA bucket.
_NV_ANGLE = "ANGLE (NVIDIA, NVIDIA GeForce GTX 980 Direct3D11 vs_5_0 ps_5_0), or similar"
@@ -153,64 +152,10 @@ def test_hardware_is_not_mistaken_for_software(monkeypatch):
assert not is_software_renderer(renderer)
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))
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):
# A hardware first draw settles the class as hardware, so a software
# candidate mid-loop is skipped instead of accepted -- otherwise the
# rejection loop still leaks probability mass onto the rasterizers.
draws = iter(
[
{"webGl:renderer": _NV_ANGLE}, # implausible at 1024x600
{"webGl:renderer": _LLVMPIPE}, # plausible, but wrong class
{"webGl:renderer": _INTEL}, # the coherent hardware answer
]
)
monkeypatch.setattr(fingerprints, "sample_webgl", lambda *a, **kw: next(draws))
assert sample_webgl_for_screen("lin", 1024, 600)["webGl:renderer"] == _INTEL
def test_falls_back_to_the_first_draw_when_nothing_is_coherent(monkeypatch):
monkeypatch.setattr(
fingerprints, "sample_webgl", lambda *a, **kw: {"webGl:renderer": _NV_ANGLE}
)
fp = sample_webgl_for_screen("win", 800, 600, attempts=4)
assert fp["webGl:renderer"] == _NV_ANGLE
def test_a_plausible_first_draw_costs_one_query(monkeypatch):
# sample_webgl opens a fresh sqlite connection per call, and the common
# case (any screen at or above the floor) must not pay for 32 of them.
calls = []
def _counted(*args, **kwargs):
calls.append(args)
return {"webGl:renderer": _NV_ANGLE}
monkeypatch.setattr(fingerprints, "sample_webgl", _counted)
sample_webgl_for_screen("win", 1920, 1080)
assert len(calls) == 1
@pytest.mark.parametrize("target_os", ["win", "mac", "lin"])
def test_sampled_gpu_is_coherent_with_the_screen(target_os):
# Against the real webgl_data.db pool.
for _ in range(25):
fp = sample_webgl_for_screen(target_os, 1280, 800)
for seed in range(25):
fp = sample_webgl_for_screen(target_os, 1280, 800, seed=seed)
assert gpu_screen_is_plausible(fp.get("webGl:renderer"), 1280, 800)
@@ -257,8 +202,8 @@ def test_screen_floor_is_a_no_op_without_screen_values():
@pytest.mark.parametrize("target_os", ["windows", "macos", "linux"])
def test_context_fingerprints_get_the_same_treatment(target_os):
"""generate_context_fingerprint() is the per-context API #729 names, and
build-tester drives the browser through it. It sampled the GPU with a bare
sample_webgl() and never applied the floor, so the coherence fix reached
build-tester drives the browser through it. It drew the GPU without the
screen and never applied the floor, so the coherence fix reached
launch_options() only."""
from camoufox.fingerprints import generate_context_fingerprint
+23
View File
@@ -0,0 +1,23 @@
"""The per-context init script hands a WebRTC IP to the setter for its family."""
import pytest
from camoufox.exceptions import InvalidIP
from camoufox.fingerprints import _build_init_script
def test_ipv4_goes_to_the_ipv4_setter():
script = _build_init_script({"webrtcIP": "203.0.113.7"})
assert 'w.setWebRTCIPv4("203.0.113.7")' in script
assert "setWebRTCIPv6(" not in script
def test_ipv6_goes_to_the_ipv6_setter():
script = _build_init_script({"webrtcIP": "2001:db8::7"})
assert 'w.setWebRTCIPv6("2001:db8::7")' in script
assert "2001:db8::7" not in script.split("setWebRTCIPv6")[0]
def test_an_invalid_address_is_refused():
with pytest.raises(InvalidIP):
_build_init_script({"webrtcIP": "not-an-ip"})