mirror of
https://github.com/daijro/camoufox.git
synced 2026-10-03 16:00:19 +00:00
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:
co-authored by
Claude Opus 5.5
parent
c769df8ea8
commit
180e6553cc
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024-2026 daijro and the Camoufox contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+7
-16
@@ -7,7 +7,7 @@
|
||||
</div>
|
||||
|
||||
> [!NOTE]
|
||||
> All the the latest documentation is avaliable [here](https://camoufox.com/python).
|
||||
> All the latest documentation is available [here](https://camoufox.com/python).
|
||||
|
||||
---
|
||||
|
||||
@@ -33,18 +33,10 @@ The `geoip` parameter is optional, but heavily recommended if you are using prox
|
||||
|
||||
Next, download the Camoufox browser:
|
||||
|
||||
**Windows**
|
||||
|
||||
```bash
|
||||
camoufox fetch
|
||||
```
|
||||
|
||||
**MacOS & Linux**
|
||||
|
||||
```bash
|
||||
python3 -m camoufox fetch
|
||||
```
|
||||
|
||||
To uninstall, run `camoufox remove`.
|
||||
|
||||
---
|
||||
@@ -75,7 +67,7 @@ camoufox gui
|
||||
|
||||
---
|
||||
|
||||
## CLI Mananger
|
||||
## CLI Manager
|
||||
|
||||
#### Demonstration
|
||||
|
||||
@@ -246,13 +238,12 @@ Display the Python package version, active browser version, channel, and update
|
||||
```bash
|
||||
> camoufox version
|
||||
Python Packages
|
||||
Camoufox v0.5.0
|
||||
Camoufox v0.5.6
|
||||
fpgen v1.3.0
|
||||
Apify Fingerprints v0.10.0
|
||||
Playwright v1.57.1.dev0+g732639b35.d20251217
|
||||
Playwright v1.62.0
|
||||
Browser
|
||||
Active official/stable/135.0.1-beta.24
|
||||
Current browser v135.0.1-beta.24
|
||||
Active official/stable/152.0.4-beta.31
|
||||
Current browser v152.0.4-beta.31
|
||||
Installed Yes
|
||||
Latest in official/stable? Yes
|
||||
Last Sync 2026-03-07 00:23
|
||||
@@ -303,4 +294,4 @@ Launch a remote Playwright server.
|
||||
|
||||
## Usage
|
||||
|
||||
All of the latest stable documentation is avaliable at [camoufox.com/python](https://camoufox.com/python).
|
||||
All of the latest stable documentation is available at [camoufox.com/python](https://camoufox.com/python).
|
||||
|
||||
@@ -36,7 +36,6 @@ from .multiversion import (
|
||||
remove_version,
|
||||
save_config,
|
||||
save_repo_cache,
|
||||
set_active,
|
||||
)
|
||||
from .pkgman import (
|
||||
INSTALL_DIR,
|
||||
@@ -681,7 +680,7 @@ def _list_installed(show_paths: bool):
|
||||
rprint(" └── Not configured", fg="yellow")
|
||||
|
||||
|
||||
def _list_all(_show_paths: bool):
|
||||
def _list_all(show_paths: bool):
|
||||
"""
|
||||
List all available versions from synced repos
|
||||
"""
|
||||
@@ -721,6 +720,8 @@ def _list_all(_show_paths: bool):
|
||||
click.secho(" (installed, active)", fg="green", bold=True, nl=False)
|
||||
else:
|
||||
click.secho(" (installed)", fg="green", nl=False)
|
||||
if show_paths:
|
||||
click.secho(f" -> {inst.path}", fg="bright_black", nl=False)
|
||||
|
||||
click.echo()
|
||||
|
||||
@@ -866,7 +867,6 @@ class VersionInfo:
|
||||
self._header("Python Packages")
|
||||
self._pkg("Camoufox", "camoufox")
|
||||
self._pkg("fpgen", "fpgen")
|
||||
self._pkg("Apify Fingerprints", "apify_fingerprint_datapoints")
|
||||
self._pkg("Playwright", "playwright")
|
||||
|
||||
def browser(self):
|
||||
@@ -952,7 +952,7 @@ class VersionInfo:
|
||||
|
||||
self._header("GeoIP")
|
||||
if not ALLOW_GEOIP:
|
||||
# geoip2 package not installed
|
||||
# maxminddb not installed
|
||||
self._row("Status", "Not supported (install camoufox[geoip])", style="dim")
|
||||
else:
|
||||
mmdb_path = get_mmdb_path()
|
||||
|
||||
@@ -29,9 +29,3 @@ class CONSTRAINTS:
|
||||
# actually break get moved.
|
||||
PLAYWRIGHT_BROWSER_FLOORS = (((1, 61), 'beta.30'),)
|
||||
|
||||
@staticmethod
|
||||
def as_range() -> str:
|
||||
"""
|
||||
Returns the version range as a string.
|
||||
"""
|
||||
return f">={CONSTRAINTS.MIN_VERSION}, <{CONSTRAINTS.MAX_VERSION}"
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import asyncio
|
||||
import json as _json
|
||||
import urllib.request
|
||||
from functools import partial
|
||||
from typing import Any, Dict, List, Optional, Union, overload
|
||||
from urllib.parse import urlparse
|
||||
from typing import Any, Dict, Optional, Tuple, Union, overload
|
||||
|
||||
from playwright.async_api import (
|
||||
Browser,
|
||||
@@ -16,6 +13,7 @@ from typing_extensions import Literal
|
||||
from camoufox.virtdisplay import VirtualDisplay
|
||||
|
||||
from .fingerprints import generate_context_fingerprint
|
||||
from .ip import Proxy, proxy_exit_geo
|
||||
from .utils import (
|
||||
async_attach_vd,
|
||||
attach_no_viewport_default,
|
||||
@@ -178,31 +176,9 @@ async def _launch(
|
||||
return await async_attach_vd(browser, virtual_display)
|
||||
|
||||
|
||||
def _proxy_url_with_creds(proxy: Dict[str, str]) -> str:
|
||||
"""Builds a proxy URL string with embedded credentials."""
|
||||
parsed = urlparse(proxy.get("server", ""))
|
||||
user = proxy.get("username", "")
|
||||
pwd = proxy.get("password", "")
|
||||
if user and pwd:
|
||||
return f"{parsed.scheme}://{user}:{pwd}@{parsed.netloc}"
|
||||
return proxy.get("server", "")
|
||||
|
||||
|
||||
async def _resolve_proxy_geo(proxy: Dict[str, str]) -> Dict[str, Optional[str]]:
|
||||
"""Queries ip-api.com through the proxy for the exit IP and timezone."""
|
||||
proxy_url = _proxy_url_with_creds(proxy)
|
||||
|
||||
def _fetch() -> Dict[str, Optional[str]]:
|
||||
handler = urllib.request.ProxyHandler({"http": proxy_url, "https": proxy_url})
|
||||
opener = urllib.request.build_opener(handler)
|
||||
try:
|
||||
with opener.open("http://ip-api.com/json?fields=query,timezone", timeout=10) as resp:
|
||||
data = _json.loads(resp.read())
|
||||
return {"ip": data.get("query") or None, "timezone": data.get("timezone") or None}
|
||||
except Exception:
|
||||
return {"ip": None, "timezone": None}
|
||||
|
||||
return await asyncio.get_event_loop().run_in_executor(None, _fetch)
|
||||
async def _resolve_proxy_geo(proxy: Dict[str, str]) -> Tuple[str, str]:
|
||||
"""The proxy's exit IP and timezone, looked up off the event loop."""
|
||||
return await asyncio.to_thread(proxy_exit_geo, Proxy(**proxy).as_string())
|
||||
|
||||
|
||||
async def AsyncNewContext(
|
||||
@@ -219,27 +195,31 @@ async def AsyncNewContext(
|
||||
"""
|
||||
Creates a new browser context with a unique fingerprint identity.
|
||||
|
||||
Each context gets its own real fingerprint preset (navigator, screen, WebGL, fonts, etc.)
|
||||
with unique seeds for audio, canvas, and font spacing noise. All values are applied
|
||||
Each context gets its own identity (navigator, screen, WebGL, fonts, voices),
|
||||
drawn by fpgen unless a preset is given, with its own audio noise seed. All values are applied
|
||||
via addInitScript so they self-destruct before page scripts can detect them.
|
||||
|
||||
Parameters:
|
||||
browser: A Browser instance from AsyncNewBrowser or AsyncCamoufox.
|
||||
preset: A specific fingerprint preset dict to use. If None, picks randomly.
|
||||
os: Target OS for preset selection ("windows", "macos", "linux").
|
||||
ff_version: Firefox version string for UA patching.
|
||||
webrtc_ip: IPv4 address to spoof for WebRTC ICE candidates.
|
||||
preset: A fingerprint preset dict to use. If None, fpgen draws a new identity.
|
||||
os: Target OS for the drawn identity ("windows", "macos", "linux").
|
||||
ff_version: Firefox major version to claim in the UA. Defaults to the browser's own.
|
||||
webrtc_ip: IPv4 or IPv6 address to spoof for WebRTC ICE candidates.
|
||||
proxy: Per-context proxy (Playwright format: {"server": "...", "username": "...", "password": "..."}).
|
||||
Unless webrtc_ip and timezone_id are both given, they are looked up from the
|
||||
proxy's exit IP; InvalidIP is raised if that lookup fails.
|
||||
geolocation: Per-context geolocation ({"latitude": float, "longitude": float}).
|
||||
**context_kwargs: Additional Playwright new_context() options.
|
||||
"""
|
||||
# The drawn UA carries fpgen's Firefox version, which must not disagree with
|
||||
# the browser the page is actually talking to.
|
||||
ff_version = ff_version or browser.version.split('.', 1)[0]
|
||||
|
||||
# Auto-derive WebRTC IP and timezone from proxy's exit IP when not explicitly provided
|
||||
if proxy and (not webrtc_ip or "timezone_id" not in context_kwargs):
|
||||
geo = await _resolve_proxy_geo(proxy)
|
||||
if not webrtc_ip:
|
||||
webrtc_ip = geo["ip"]
|
||||
if "timezone_id" not in context_kwargs and geo["timezone"]:
|
||||
context_kwargs["timezone_id"] = geo["timezone"]
|
||||
exit_ip, timezone = await _resolve_proxy_geo(proxy)
|
||||
webrtc_ip = webrtc_ip or exit_ip
|
||||
context_kwargs.setdefault("timezone_id", timezone)
|
||||
|
||||
fp = await asyncio.get_event_loop().run_in_executor(
|
||||
None,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""Whole-identity coherence: the checks that look at more than one field.
|
||||
|
||||
Camoufox assembles an identity from several pools -- the navigator and screen
|
||||
from the fingerprint generator, the GPU from `webgl_data.db`, fonts and voices
|
||||
from its own catalogues, media devices from `media-devices.json`. Each pool is
|
||||
sampled on its own, so a combination that no machine has ever had can be built
|
||||
from the fingerprint generator, the GPU from fpgen's WebGL records, fonts and
|
||||
voices from its own catalogues, media devices from `media-devices.json`. Each
|
||||
pool is sampled on its own, so a combination that no machine has ever had can be built
|
||||
out of individually plausible parts: an Apple M1 with 2 cores, a Mac reporting a
|
||||
Braswell Atom GPU, a Linux identity whose platform says armv81 while its user
|
||||
agent says x86_64.
|
||||
@@ -43,10 +43,15 @@ APPLE_SILICON_CORES = frozenset({8, 10, 11, 12, 14, 16, 20, 24, 28, 32})
|
||||
# Linux reports 1, or 2 under HiDPI, with GNOME fractional scaling giving the
|
||||
# 1.25/1.5/1.75 steps. Values outside these (1.818, 1.09, 1.36) are scraped
|
||||
# artefacts -- a browser zoom level folded into the ratio, not a display mode.
|
||||
#
|
||||
# Ascending tuples, not sets: the repair keeps the first of two equally near
|
||||
# steps, and a frozenset literal iterates in a different order when compiled
|
||||
# than when loaded from a .pyc -- so a set made the first launch repair an
|
||||
# identity differently from every later one.
|
||||
PLAUSIBLE_DPR = {
|
||||
'win': frozenset({1, 1.25, 1.5, 1.75, 2, 2.5, 3}),
|
||||
'mac': frozenset({1, 2}),
|
||||
'lin': frozenset({1, 1.25, 1.5, 1.75, 2}),
|
||||
'win': (1, 1.25, 1.5, 1.75, 2, 2.5, 3),
|
||||
'mac': (1, 2),
|
||||
'lin': (1, 1.25, 1.5, 1.75, 2),
|
||||
}
|
||||
|
||||
# colorDepth: Firefox reports 24, or 30 on a deep-colour display. macOS defaults
|
||||
@@ -78,9 +83,9 @@ BROWSER_CHROME_HEIGHT = 86
|
||||
|
||||
# GPU strings that are not possible on macOS. Firefox on a Mac reports Apple
|
||||
# Silicon as "Apple M1, or similar", and Intel Macs as an Intel Iris/UHD/HD
|
||||
# 4000-6000 part; ANGLE is Windows-only (Direct3D), and these two rows in
|
||||
# webgl_data.db are a Braswell Atom IGP and a desktop PC card, neither of which
|
||||
# shipped in any Mac.
|
||||
# 4000-6000 part; ANGLE is Windows-only (Direct3D), and the other two, which
|
||||
# fpgen records from macOS, are a Braswell Atom IGP and a desktop PC card,
|
||||
# neither of which shipped in any Mac.
|
||||
_NOT_A_MAC_GPU = ('ANGLE', 'Intel(R) HD Graphics 400', 'Radeon R9 200 Series', 'llvmpipe')
|
||||
|
||||
|
||||
@@ -128,7 +133,7 @@ def _repair_apple_silicon_cores(config: Dict[str, Any], target_os: str) -> None:
|
||||
def gpu_fits_os(renderer: Optional[str], target_os: str) -> bool:
|
||||
"""Whether this renderer string is one the OS can report.
|
||||
|
||||
Used both to check a finished identity and to filter `webgl_data.db` before
|
||||
Used both to check a finished identity and to filter fpgen's GPUs before
|
||||
sampling, so the two can never disagree about what a Mac may claim.
|
||||
"""
|
||||
renderer = str(renderer or '')
|
||||
|
||||
@@ -38,14 +38,6 @@ class UnsupportedOS(Exception):
|
||||
...
|
||||
|
||||
|
||||
class UnknownProperty(Exception):
|
||||
"""
|
||||
Raised when the property is unknown.
|
||||
"""
|
||||
|
||||
...
|
||||
|
||||
|
||||
class InvalidPropertyType(Exception):
|
||||
"""
|
||||
Raised when the property type is invalid.
|
||||
@@ -62,22 +54,6 @@ class InvalidAddonPath(FileNotFoundError):
|
||||
...
|
||||
|
||||
|
||||
class InvalidDebugPort(ValueError):
|
||||
"""
|
||||
Raised when the debug port is invalid.
|
||||
"""
|
||||
|
||||
...
|
||||
|
||||
|
||||
class MissingDebugPort(ValueError):
|
||||
"""
|
||||
Raised when the debug port is missing.
|
||||
"""
|
||||
|
||||
...
|
||||
|
||||
|
||||
class LocaleError(Exception):
|
||||
"""
|
||||
Raised when the locale is invalid.
|
||||
@@ -141,7 +117,7 @@ class UnknownLanguage(InvalidLocale):
|
||||
|
||||
class NotInstalledGeoIPExtra(ImportError):
|
||||
"""
|
||||
Raised when the geoip2 module is not installed.
|
||||
Raised when the maxminddb module is not installed.
|
||||
"""
|
||||
|
||||
...
|
||||
@@ -149,7 +125,7 @@ class NotInstalledGeoIPExtra(ImportError):
|
||||
|
||||
class NonFirefoxFingerprint(Exception):
|
||||
"""
|
||||
Raised when a passed Browserforge fingerprint is invalid.
|
||||
Raised when a passed fingerprint is not a Firefox fingerprint.
|
||||
"""
|
||||
|
||||
...
|
||||
|
||||
@@ -2881,76 +2881,6 @@
|
||||
"Zuzana:cs-CZ:local"
|
||||
]
|
||||
},
|
||||
{
|
||||
"navigator": {
|
||||
"userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:149.0) Gecko/20100101 Firefox/149.0",
|
||||
"platform": "MacIntel",
|
||||
"hardwareConcurrency": 2,
|
||||
"maxTouchPoints": 0
|
||||
},
|
||||
"screen": {
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"colorDepth": 30,
|
||||
"availWidth": 1280,
|
||||
"availHeight": 800,
|
||||
"devicePixelRatio": 2
|
||||
},
|
||||
"webgl": {
|
||||
"unmaskedVendor": "Intel Inc.",
|
||||
"unmaskedRenderer": "Intel 945GM, or similar"
|
||||
},
|
||||
"speechVoices": [
|
||||
"Alex:en-US:local",
|
||||
"Alice:it-IT:local",
|
||||
"Alva:sv-SE:local",
|
||||
"Amelie:fr-CA:local",
|
||||
"Anna:de-DE:local",
|
||||
"Carmit:he-IL:local",
|
||||
"Damayanti:id-ID:local",
|
||||
"Daniel:en-GB:local",
|
||||
"Diego:es-AR:local",
|
||||
"Ellen:nl-BE:local",
|
||||
"Fiona:en-scotland:local",
|
||||
"Fred:en-US:local",
|
||||
"Ioana:ro-RO:local",
|
||||
"Joana:pt-PT:local",
|
||||
"Jorge:es-ES:local",
|
||||
"Juan:es-MX:local",
|
||||
"Kanya:th-TH:local",
|
||||
"Karen:en-AU:local",
|
||||
"Kyoko:ja-JP:local",
|
||||
"Laura:sk-SK:local",
|
||||
"Lekha:hi-IN:local",
|
||||
"Luca:it-IT:local",
|
||||
"Luciana:pt-BR:local",
|
||||
"Maged:ar-SA:local",
|
||||
"Mariska:hu-HU:local",
|
||||
"Mei-Jia:zh-TW:local",
|
||||
"Melina:el-GR:local",
|
||||
"Milena:ru-RU:local",
|
||||
"Moira:en-IE:local",
|
||||
"Monica:es-ES:local",
|
||||
"Nora:nb-NO:local",
|
||||
"Paulina:es-MX:local",
|
||||
"Rishi:en-IN:local",
|
||||
"Samantha:en-US:local",
|
||||
"Sara:da-DK:local",
|
||||
"Satu:fi-FI:local",
|
||||
"Sin-ji:zh-HK:local",
|
||||
"Tessa:en-ZA:local",
|
||||
"Thomas:fr-FR:local",
|
||||
"Ting-Ting:zh-CN:local",
|
||||
"Veena:en-IN:local",
|
||||
"Victoria:en-US:local",
|
||||
"Xander:nl-NL:local",
|
||||
"Yelda:tr-TR:local",
|
||||
"Yuna:ko-KR:local",
|
||||
"Yuri:ru-RU:local",
|
||||
"Zosia:pl-PL:local",
|
||||
"Zuzana:cs-CZ:local"
|
||||
]
|
||||
},
|
||||
{
|
||||
"navigator": {
|
||||
"userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:150.0) Gecko/20100101 Firefox/150.0",
|
||||
@@ -6895,76 +6825,6 @@
|
||||
"Zuzana:cs-CZ:local"
|
||||
]
|
||||
},
|
||||
{
|
||||
"navigator": {
|
||||
"userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:149.0) Gecko/20100101 Firefox/149.0",
|
||||
"platform": "MacIntel",
|
||||
"hardwareConcurrency": 4,
|
||||
"maxTouchPoints": 0
|
||||
},
|
||||
"screen": {
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"colorDepth": 30,
|
||||
"availWidth": 1920,
|
||||
"availHeight": 984,
|
||||
"devicePixelRatio": 1
|
||||
},
|
||||
"webgl": {
|
||||
"unmaskedVendor": "Intel Inc.",
|
||||
"unmaskedRenderer": "Intel 945GM, or similar"
|
||||
},
|
||||
"speechVoices": [
|
||||
"Alex:en-US:local",
|
||||
"Alice:it-IT:local",
|
||||
"Alva:sv-SE:local",
|
||||
"Amelie:fr-CA:local",
|
||||
"Anna:de-DE:local",
|
||||
"Carmit:he-IL:local",
|
||||
"Damayanti:id-ID:local",
|
||||
"Daniel:en-GB:local",
|
||||
"Diego:es-AR:local",
|
||||
"Ellen:nl-BE:local",
|
||||
"Fiona:en-scotland:local",
|
||||
"Fred:en-US:local",
|
||||
"Ioana:ro-RO:local",
|
||||
"Joana:pt-PT:local",
|
||||
"Jorge:es-ES:local",
|
||||
"Juan:es-MX:local",
|
||||
"Kanya:th-TH:local",
|
||||
"Karen:en-AU:local",
|
||||
"Kyoko:ja-JP:local",
|
||||
"Laura:sk-SK:local",
|
||||
"Lekha:hi-IN:local",
|
||||
"Luca:it-IT:local",
|
||||
"Luciana:pt-BR:local",
|
||||
"Maged:ar-SA:local",
|
||||
"Mariska:hu-HU:local",
|
||||
"Mei-Jia:zh-TW:local",
|
||||
"Melina:el-GR:local",
|
||||
"Milena:ru-RU:local",
|
||||
"Moira:en-IE:local",
|
||||
"Monica:es-ES:local",
|
||||
"Nora:nb-NO:local",
|
||||
"Paulina:es-MX:local",
|
||||
"Rishi:en-IN:local",
|
||||
"Samantha:en-US:local",
|
||||
"Sara:da-DK:local",
|
||||
"Satu:fi-FI:local",
|
||||
"Sin-ji:zh-HK:local",
|
||||
"Tessa:en-ZA:local",
|
||||
"Thomas:fr-FR:local",
|
||||
"Ting-Ting:zh-CN:local",
|
||||
"Veena:en-IN:local",
|
||||
"Victoria:en-US:local",
|
||||
"Xander:nl-NL:local",
|
||||
"Yelda:tr-TR:local",
|
||||
"Yuna:ko-KR:local",
|
||||
"Yuri:ru-RU:local",
|
||||
"Zosia:pl-PL:local",
|
||||
"Zuzana:cs-CZ:local"
|
||||
]
|
||||
},
|
||||
{
|
||||
"navigator": {
|
||||
"userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:149.0) Gecko/20100101 Firefox/149.0",
|
||||
@@ -8256,33 +8116,6 @@
|
||||
"Microsoft Zira Desktop - English (United States):en-US:local"
|
||||
]
|
||||
},
|
||||
{
|
||||
"navigator": {
|
||||
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:150.0) Gecko/20100101 Firefox/150.0",
|
||||
"platform": "Win32",
|
||||
"hardwareConcurrency": 2,
|
||||
"maxTouchPoints": 0
|
||||
},
|
||||
"screen": {
|
||||
"width": 1366,
|
||||
"height": 768,
|
||||
"colorDepth": 24,
|
||||
"availWidth": 1366,
|
||||
"availHeight": 728,
|
||||
"devicePixelRatio": 1
|
||||
},
|
||||
"webgl": {
|
||||
"unmaskedVendor": "Google Inc. (AMD)",
|
||||
"unmaskedRenderer": "ANGLE (AMD, Radeon R9 200 Series Direct3D11 vs_4_0 ps_4_0), or similar"
|
||||
},
|
||||
"speechVoices": [
|
||||
"Microsoft Helena - Spanish (Spain):es-ES:local",
|
||||
"Microsoft Laura - Spanish (Spain):es-ES:local",
|
||||
"Microsoft Pablo - Spanish (Spain):es-ES:local",
|
||||
"Microsoft Helena Desktop - Spanish (Spain):es-ES:local",
|
||||
"Microsoft Zira Desktop - English (United States):en-US:local"
|
||||
]
|
||||
},
|
||||
{
|
||||
"navigator": {
|
||||
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:150.0) Gecko/20100101 Firefox/150.0",
|
||||
@@ -8534,33 +8367,6 @@
|
||||
"Microsoft Zira Desktop - English (United States):en-US:local"
|
||||
]
|
||||
},
|
||||
{
|
||||
"navigator": {
|
||||
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:150.0) Gecko/20100101 Firefox/150.0",
|
||||
"platform": "Win32",
|
||||
"hardwareConcurrency": 4,
|
||||
"maxTouchPoints": 0
|
||||
},
|
||||
"screen": {
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"colorDepth": 24,
|
||||
"availWidth": 1920,
|
||||
"availHeight": 1040,
|
||||
"devicePixelRatio": 1
|
||||
},
|
||||
"webgl": {
|
||||
"unmaskedVendor": "Google Inc. (Intel)",
|
||||
"unmaskedRenderer": "ANGLE (Intel, Intel(R) HD Graphics Direct3D11 vs_4_1 ps_4_1), or similar"
|
||||
},
|
||||
"speechVoices": [
|
||||
"Microsoft Helena - Spanish (Spain):es-ES:local",
|
||||
"Microsoft Laura - Spanish (Spain):es-ES:local",
|
||||
"Microsoft Pablo - Spanish (Spain):es-ES:local",
|
||||
"Microsoft Helena Desktop - Spanish (Spain):es-ES:local",
|
||||
"Microsoft Zira Desktop - English (United States):en-US:local"
|
||||
]
|
||||
},
|
||||
{
|
||||
"navigator": {
|
||||
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:150.0) Gecko/20100101 Firefox/150.0",
|
||||
@@ -9588,32 +9394,6 @@
|
||||
"Microsoft Zira Desktop - English (United States):en-US:local"
|
||||
]
|
||||
},
|
||||
{
|
||||
"navigator": {
|
||||
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:150.0) Gecko/20100101 Firefox/150.0",
|
||||
"platform": "Win32",
|
||||
"hardwareConcurrency": 4,
|
||||
"maxTouchPoints": 0
|
||||
},
|
||||
"screen": {
|
||||
"width": 1366,
|
||||
"height": 768,
|
||||
"colorDepth": 24,
|
||||
"availWidth": 1299,
|
||||
"availHeight": 768,
|
||||
"devicePixelRatio": 1
|
||||
},
|
||||
"webgl": {
|
||||
"unmaskedVendor": "Google Inc. (Intel)",
|
||||
"unmaskedRenderer": "ANGLE (Intel, Intel(R) HD Graphics Direct3D11 vs_4_1 ps_4_1), or similar"
|
||||
},
|
||||
"speechVoices": [
|
||||
"Microsoft Raul - Spanish (Mexico):es-MX:local",
|
||||
"Microsoft Sabina - Spanish (Mexico):es-MX:local",
|
||||
"Microsoft Sabina Desktop - Spanish (Mexico):es-MX:local",
|
||||
"Microsoft Zira Desktop - English (United States):en-US:local"
|
||||
]
|
||||
},
|
||||
{
|
||||
"navigator": {
|
||||
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:150.0) Gecko/20100101 Firefox/150.0",
|
||||
@@ -9810,29 +9590,6 @@
|
||||
"Microsoft David Desktop - English (United States):en-US:local"
|
||||
]
|
||||
},
|
||||
{
|
||||
"navigator": {
|
||||
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:150.0) Gecko/20100101 Firefox/150.0",
|
||||
"platform": "Win32",
|
||||
"hardwareConcurrency": 2,
|
||||
"maxTouchPoints": 0
|
||||
},
|
||||
"screen": {
|
||||
"width": 1680,
|
||||
"height": 1050,
|
||||
"colorDepth": 24,
|
||||
"availWidth": 1680,
|
||||
"availHeight": 1010,
|
||||
"devicePixelRatio": 1
|
||||
},
|
||||
"webgl": {
|
||||
"unmaskedVendor": "Google Inc. (NVIDIA)",
|
||||
"unmaskedRenderer": "ANGLE (NVIDIA, NVIDIA GeForce 8800 GTX Direct3D11 vs_4_0 ps_4_0), or similar"
|
||||
},
|
||||
"speechVoices": [
|
||||
"Microsoft Zira Desktop - English (United States):en-US:local"
|
||||
]
|
||||
},
|
||||
{
|
||||
"navigator": {
|
||||
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:150.0) Gecko/20100101 Firefox/150.0",
|
||||
@@ -9887,26 +9644,6 @@
|
||||
"Microsoft Zira Desktop - English (United States):en-US:local"
|
||||
]
|
||||
},
|
||||
{
|
||||
"navigator": {
|
||||
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:149.0) Gecko/20100101 Firefox/149.0",
|
||||
"platform": "Win32",
|
||||
"hardwareConcurrency": 4,
|
||||
"maxTouchPoints": 0
|
||||
},
|
||||
"screen": {
|
||||
"width": 1366,
|
||||
"height": 768,
|
||||
"colorDepth": 24,
|
||||
"availWidth": 1366,
|
||||
"availHeight": 720,
|
||||
"devicePixelRatio": 1
|
||||
},
|
||||
"webgl": {
|
||||
"unmaskedVendor": "Google Inc. (Intel)",
|
||||
"unmaskedRenderer": "ANGLE (Intel, Intel(R) HD Graphics Direct3D11 vs_4_1 ps_4_1), or similar"
|
||||
}
|
||||
},
|
||||
{
|
||||
"navigator": {
|
||||
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:149.0) Gecko/20100101 Firefox/149.0",
|
||||
@@ -10893,33 +10630,6 @@
|
||||
"Microsoft Zira Desktop - English (United States):en-US:local"
|
||||
]
|
||||
},
|
||||
{
|
||||
"navigator": {
|
||||
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:149.0) Gecko/20100101 Firefox/149.0",
|
||||
"platform": "Win32",
|
||||
"hardwareConcurrency": 8,
|
||||
"maxTouchPoints": 0
|
||||
},
|
||||
"screen": {
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"colorDepth": 24,
|
||||
"availWidth": 1280,
|
||||
"availHeight": 752,
|
||||
"devicePixelRatio": 1.5
|
||||
},
|
||||
"webgl": {
|
||||
"unmaskedVendor": "Google Inc. (Unknown)",
|
||||
"unmaskedRenderer": "ANGLE (Unknown, Generic Renderer Direct3D11 vs_5_0 ps_5_0), or similar"
|
||||
},
|
||||
"speechVoices": [
|
||||
"Microsoft Hortense - French (France):fr-FR:local",
|
||||
"Microsoft Julie - French (France):fr-FR:local",
|
||||
"Microsoft Paul - French (France):fr-FR:local",
|
||||
"Microsoft Hortense Desktop - French:fr-FR:local",
|
||||
"Microsoft Zira Desktop - English (United States):en-US:local"
|
||||
]
|
||||
},
|
||||
{
|
||||
"navigator": {
|
||||
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:149.0) Gecko/20100101 Firefox/149.0",
|
||||
@@ -10976,33 +10686,6 @@
|
||||
"Microsoft Zira Desktop - English (United States):en-US:local"
|
||||
]
|
||||
},
|
||||
{
|
||||
"navigator": {
|
||||
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:149.0) Gecko/20100101 Firefox/149.0",
|
||||
"platform": "Win32",
|
||||
"hardwareConcurrency": 8,
|
||||
"maxTouchPoints": 0
|
||||
},
|
||||
"screen": {
|
||||
"width": 1366,
|
||||
"height": 768,
|
||||
"colorDepth": 24,
|
||||
"availWidth": 1366,
|
||||
"availHeight": 720,
|
||||
"devicePixelRatio": 1
|
||||
},
|
||||
"webgl": {
|
||||
"unmaskedVendor": "Google Inc. (Unknown)",
|
||||
"unmaskedRenderer": "ANGLE (Unknown, Adreno (TM) 650 Direct3D11 vs_5_0 ps_5_0), or similar"
|
||||
},
|
||||
"speechVoices": [
|
||||
"Microsoft David - English (United States):en-US:local",
|
||||
"Microsoft Mark - English (United States):en-US:local",
|
||||
"Microsoft Zira - English (United States):en-US:local",
|
||||
"Microsoft David Desktop - English (United States):en-US:local",
|
||||
"Microsoft Zira Desktop - English (United States):en-US:local"
|
||||
]
|
||||
},
|
||||
{
|
||||
"navigator": {
|
||||
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:149.0) Gecko/20100101 Firefox/149.0",
|
||||
@@ -11436,85 +11119,6 @@
|
||||
"Microsoft Zira Desktop - English (United States):en-US:local"
|
||||
]
|
||||
},
|
||||
{
|
||||
"navigator": {
|
||||
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:149.0) Gecko/20100101 Firefox/149.0",
|
||||
"platform": "Win32",
|
||||
"hardwareConcurrency": 12,
|
||||
"maxTouchPoints": 10
|
||||
},
|
||||
"screen": {
|
||||
"width": 1440,
|
||||
"height": 960,
|
||||
"colorDepth": 24,
|
||||
"availWidth": 1440,
|
||||
"availHeight": 912,
|
||||
"devicePixelRatio": 2
|
||||
},
|
||||
"webgl": {
|
||||
"unmaskedVendor": "Google Inc. (Unknown)",
|
||||
"unmaskedRenderer": "ANGLE (Unknown, Generic Renderer Direct3D11 vs_5_0 ps_5_0), or similar"
|
||||
},
|
||||
"speechVoices": [
|
||||
"Microsoft David - English (United States):en-US:local",
|
||||
"Microsoft Mark - English (United States):en-US:local",
|
||||
"Microsoft Zira - English (United States):en-US:local",
|
||||
"Microsoft David Desktop - English (United States):en-US:local",
|
||||
"Microsoft Zira Desktop - English (United States):en-US:local"
|
||||
]
|
||||
},
|
||||
{
|
||||
"navigator": {
|
||||
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:149.0) Gecko/20100101 Firefox/149.0",
|
||||
"platform": "Win32",
|
||||
"hardwareConcurrency": 4,
|
||||
"maxTouchPoints": 0
|
||||
},
|
||||
"screen": {
|
||||
"width": 1366,
|
||||
"height": 768,
|
||||
"colorDepth": 24,
|
||||
"availWidth": 1366,
|
||||
"availHeight": 728,
|
||||
"devicePixelRatio": 1
|
||||
},
|
||||
"webgl": {
|
||||
"unmaskedVendor": "Google Inc. (Intel)",
|
||||
"unmaskedRenderer": "ANGLE (Intel, Intel(R) HD Graphics Direct3D11 vs_4_0 ps_4_0), or similar"
|
||||
},
|
||||
"speechVoices": [
|
||||
"Microsoft Raul - Spanish (Mexico):es-MX:local",
|
||||
"Microsoft Sabina - Spanish (Mexico):es-MX:local",
|
||||
"Microsoft Sabina Desktop - Spanish (Mexico):es-MX:local",
|
||||
"Microsoft Zira Desktop - English (United States):en-US:local"
|
||||
]
|
||||
},
|
||||
{
|
||||
"navigator": {
|
||||
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:149.0) Gecko/20100101 Firefox/149.0",
|
||||
"platform": "Win32",
|
||||
"hardwareConcurrency": 8,
|
||||
"maxTouchPoints": 0
|
||||
},
|
||||
"screen": {
|
||||
"width": 1536,
|
||||
"height": 864,
|
||||
"colorDepth": 24,
|
||||
"availWidth": 1536,
|
||||
"availHeight": 816,
|
||||
"devicePixelRatio": 1.25
|
||||
},
|
||||
"webgl": {
|
||||
"unmaskedVendor": "Google Inc. (Unknown)",
|
||||
"unmaskedRenderer": "ANGLE (Unknown, Adreno (TM) 650 Direct3D11 vs_5_0 ps_5_0), or similar"
|
||||
},
|
||||
"speechVoices": [
|
||||
"Microsoft Daniel - Portuguese (Brazil):pt-BR:local",
|
||||
"Microsoft Maria - Portuguese (Brazil):pt-BR:local",
|
||||
"Microsoft Maria Desktop - Portuguese(Brazil):pt-BR:local",
|
||||
"Microsoft Zira Desktop - English (United States):en-US:local"
|
||||
]
|
||||
},
|
||||
{
|
||||
"navigator": {
|
||||
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:149.0) Gecko/20100101 Firefox/149.0",
|
||||
@@ -12126,26 +11730,6 @@
|
||||
}
|
||||
],
|
||||
"linux": [
|
||||
{
|
||||
"navigator": {
|
||||
"userAgent": "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0",
|
||||
"platform": "Linux x86_64",
|
||||
"hardwareConcurrency": 8,
|
||||
"maxTouchPoints": 0
|
||||
},
|
||||
"screen": {
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"colorDepth": 24,
|
||||
"availWidth": 1920,
|
||||
"availHeight": 1080,
|
||||
"devicePixelRatio": 1
|
||||
},
|
||||
"webgl": {
|
||||
"unmaskedVendor": "NVIDIA Corporation",
|
||||
"unmaskedRenderer": "NVIDIA GeForce 8800 GTX, or similar"
|
||||
}
|
||||
},
|
||||
{
|
||||
"navigator": {
|
||||
"userAgent": "Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0",
|
||||
@@ -12466,26 +12050,6 @@
|
||||
"unmaskedRenderer": "NVIDIA GeForce GTX 980, or similar"
|
||||
}
|
||||
},
|
||||
{
|
||||
"navigator": {
|
||||
"userAgent": "Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0",
|
||||
"platform": "Linux x86_64",
|
||||
"hardwareConcurrency": 2,
|
||||
"maxTouchPoints": 0
|
||||
},
|
||||
"screen": {
|
||||
"width": 1368,
|
||||
"height": 768,
|
||||
"colorDepth": 24,
|
||||
"availWidth": 1368,
|
||||
"availHeight": 728,
|
||||
"devicePixelRatio": 1
|
||||
},
|
||||
"webgl": {
|
||||
"unmaskedVendor": "Mesa",
|
||||
"unmaskedRenderer": "GeForce 8800 GTX, or similar"
|
||||
}
|
||||
},
|
||||
{
|
||||
"navigator": {
|
||||
"userAgent": "Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0",
|
||||
@@ -12506,128 +12070,6 @@
|
||||
"unmaskedRenderer": "Intel(R) HD Graphics, or similar"
|
||||
}
|
||||
},
|
||||
{
|
||||
"navigator": {
|
||||
"userAgent": "Mozilla/5.0 (X11; Linux x86_64; rv:149.0) Gecko/20100101 Firefox/149.0",
|
||||
"platform": "Linux x86_64",
|
||||
"hardwareConcurrency": 2,
|
||||
"maxTouchPoints": 0
|
||||
},
|
||||
"screen": {
|
||||
"width": 1366,
|
||||
"height": 768,
|
||||
"colorDepth": 24,
|
||||
"availWidth": 1366,
|
||||
"availHeight": 717,
|
||||
"devicePixelRatio": 1
|
||||
},
|
||||
"webgl": {
|
||||
"unmaskedVendor": "Intel Open Source Technology Center",
|
||||
"unmaskedRenderer": "Intel(R) HD Graphics 400, or similar"
|
||||
},
|
||||
"speechVoices": [
|
||||
"Catalan:ca:remote",
|
||||
"Bishnupriya Manipuri:bpy:remote",
|
||||
"Nahuatl (Classical):nci:remote",
|
||||
"Dutch:nl:remote",
|
||||
"Azerbaijani:az:remote",
|
||||
"Kyrgyz:ky:remote",
|
||||
"English (Great Britain):en:remote",
|
||||
"Chinese (Cantonese):yue:remote",
|
||||
"English (Lancaster):en:remote",
|
||||
"Vietnamese (Northern):vi:remote",
|
||||
"Amharic:am:remote",
|
||||
"Gaelic (Irish):ga:remote",
|
||||
"Latvian:lv:remote",
|
||||
"Swahili:sw:remote",
|
||||
"Afrikaans:af:remote",
|
||||
"Malay:ms:remote",
|
||||
"Tatar:tt:remote",
|
||||
"Nepali:ne:remote",
|
||||
"Serbian:sr:remote",
|
||||
"Oriya:or:remote",
|
||||
"Arabic:ar:remote",
|
||||
"Interlingua:ia:remote",
|
||||
"Italian:it:remote",
|
||||
"Aragonese:an:remote",
|
||||
"German:de:remote",
|
||||
"Assamese:as:remote",
|
||||
"Bengali:bn:remote",
|
||||
"Georgian:ka:remote",
|
||||
"Lithuanian:lt:remote",
|
||||
"Danish:da:remote",
|
||||
"English (Caribbean):en:remote",
|
||||
"Papiamento:pap:remote",
|
||||
"Chinese (Mandarin):cmn:remote",
|
||||
"Bosnian:bs:remote",
|
||||
"Guarani:gn:remote",
|
||||
"Marathi:mr:remote",
|
||||
"Persian:fa:remote",
|
||||
"Hindi:hi:remote",
|
||||
"Bulgarian:bg:remote",
|
||||
"Lingua Franca Nova:lfn:remote",
|
||||
"Hungarian:hu:remote",
|
||||
"Icelandic:is:remote",
|
||||
"Burmese:my:remote",
|
||||
"Maltese:mt:remote",
|
||||
"Esperanto:eo:remote",
|
||||
"Portuguese (Portugal):pt:remote",
|
||||
"Spanish (Spain):es:remote",
|
||||
"Kurdish:ku:remote",
|
||||
"Lojban:jbo:remote",
|
||||
"Czech:cs:remote",
|
||||
"Turkish:tr:remote",
|
||||
"Tamil:ta:remote",
|
||||
"Spanish (Latin America):es:remote",
|
||||
"Indonesian:id:remote",
|
||||
"Armenian (West Armenia):hy:remote",
|
||||
"Kannada:kn:remote",
|
||||
"Greek (Ancient):grc:remote",
|
||||
"Armenian (East Armenia):hy:remote",
|
||||
"Greenlandic:kl:remote",
|
||||
"French (Switzerland):fr:remote",
|
||||
"Norwegian Bokmål:nb:remote",
|
||||
"Portuguese (Brazil):pt:remote",
|
||||
"Urdu:ur:remote",
|
||||
"Punjabi:pa:remote",
|
||||
"Japanese:ja:remote",
|
||||
"Romanian:ro:remote",
|
||||
"Gujarati:gu:remote",
|
||||
"Latin:la:remote",
|
||||
"Polish:pl:remote",
|
||||
"French (France):fr:remote",
|
||||
"Slovenian:sl:remote",
|
||||
"Malayalam:ml:remote",
|
||||
"Russian:ru:remote",
|
||||
"Croatian:hr:remote",
|
||||
"Korean:ko:remote",
|
||||
"Welsh:cy:remote",
|
||||
"Slovak:sk:remote",
|
||||
"poz/mi:mi:remote",
|
||||
"English (Scotland):en:remote",
|
||||
"Vietnamese (Southern):vi:remote",
|
||||
"English (West Midlands):en:remote",
|
||||
"Persian (Pinglish):fa:remote",
|
||||
"Albanian:sq:remote",
|
||||
"Finnish:fi:remote",
|
||||
"English (Received Pronunciation):en:remote",
|
||||
"Greek:el:remote",
|
||||
"French (Belgium):fr:remote",
|
||||
"Gaelic (Scottish):gd:remote",
|
||||
"Telugu:te:remote",
|
||||
"Konkani:kok:remote",
|
||||
"Sindhi:sd:remote",
|
||||
"Estonian:et:remote",
|
||||
"Basque:eu:remote",
|
||||
"Oromo:om:remote",
|
||||
"English (America):en:remote",
|
||||
"Swedish:sv:remote",
|
||||
"Macedonian:mk:remote",
|
||||
"Setswana:tn:remote",
|
||||
"Sinhala:si:remote",
|
||||
"Vietnamese (Central):vi:remote"
|
||||
]
|
||||
},
|
||||
{
|
||||
"navigator": {
|
||||
"userAgent": "Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0",
|
||||
@@ -12748,46 +12190,6 @@
|
||||
"unmaskedRenderer": "Intel(R) HD Graphics, or similar"
|
||||
}
|
||||
},
|
||||
{
|
||||
"navigator": {
|
||||
"userAgent": "Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0",
|
||||
"platform": "Linux x86_64",
|
||||
"hardwareConcurrency": 2,
|
||||
"maxTouchPoints": 0
|
||||
},
|
||||
"screen": {
|
||||
"width": 1366,
|
||||
"height": 768,
|
||||
"colorDepth": 24,
|
||||
"availWidth": 1366,
|
||||
"availHeight": 718,
|
||||
"devicePixelRatio": 1
|
||||
},
|
||||
"webgl": {
|
||||
"unmaskedVendor": "Intel Open Source Technology Center",
|
||||
"unmaskedRenderer": "Intel(R) HD Graphics 400, or similar"
|
||||
}
|
||||
},
|
||||
{
|
||||
"navigator": {
|
||||
"userAgent": "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:149.0) Gecko/20100101 Firefox/149.0",
|
||||
"platform": "Linux x86_64",
|
||||
"hardwareConcurrency": 24,
|
||||
"maxTouchPoints": 0
|
||||
},
|
||||
"screen": {
|
||||
"width": 3072,
|
||||
"height": 1728,
|
||||
"colorDepth": 24,
|
||||
"availWidth": 3072,
|
||||
"availHeight": 1728,
|
||||
"devicePixelRatio": 2
|
||||
},
|
||||
"webgl": {
|
||||
"unmaskedVendor": "NVIDIA Corporation",
|
||||
"unmaskedRenderer": "NVIDIA GeForce 8800 GTX, or similar"
|
||||
}
|
||||
},
|
||||
{
|
||||
"navigator": {
|
||||
"userAgent": "Mozilla/5.0 (X11; Linux x86_64; rv:149.0) Gecko/20100101 Firefox/149.0",
|
||||
@@ -13419,26 +12821,6 @@
|
||||
"unmaskedRenderer": "Intel(R) HD Graphics 400, or similar"
|
||||
}
|
||||
},
|
||||
{
|
||||
"navigator": {
|
||||
"userAgent": "Mozilla/5.0 (X11; Linux x86_64; rv:149.0) Gecko/20100101 Firefox/149.0",
|
||||
"platform": "Linux x86_64",
|
||||
"hardwareConcurrency": 2,
|
||||
"maxTouchPoints": 0
|
||||
},
|
||||
"screen": {
|
||||
"width": 1366,
|
||||
"height": 768,
|
||||
"colorDepth": 24,
|
||||
"availWidth": 1366,
|
||||
"availHeight": 735,
|
||||
"devicePixelRatio": 1
|
||||
},
|
||||
"webgl": {
|
||||
"unmaskedVendor": "Mesa",
|
||||
"unmaskedRenderer": "Radeon HD 5850, or similar"
|
||||
}
|
||||
},
|
||||
{
|
||||
"navigator": {
|
||||
"userAgent": "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:149.0) Gecko/20100101 Firefox/149.0",
|
||||
|
||||
@@ -208,25 +208,6 @@
|
||||
"Zuzana:cs-CZ:local"
|
||||
]
|
||||
},
|
||||
{
|
||||
"navigator": {
|
||||
"userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.13; rv:109.0) Gecko/20100101 Firefox/115.0",
|
||||
"platform": "MacIntel",
|
||||
"hardwareConcurrency": 4,
|
||||
"maxTouchPoints": 0
|
||||
},
|
||||
"screen": {
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"colorDepth": 30,
|
||||
"availWidth": 1280,
|
||||
"availHeight": 714
|
||||
},
|
||||
"webgl": {
|
||||
"unmaskedVendor": "Intel Inc.",
|
||||
"unmaskedRenderer": "Intel(R) HD Graphics"
|
||||
}
|
||||
},
|
||||
{
|
||||
"navigator": {
|
||||
"userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:147.0) Gecko/20100101 Firefox/147.0",
|
||||
@@ -337,75 +318,6 @@
|
||||
"unmaskedRenderer": "Apple M1, or similar"
|
||||
}
|
||||
},
|
||||
{
|
||||
"navigator": {
|
||||
"userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:148.0) Gecko/20100101 Firefox/148.0",
|
||||
"platform": "MacIntel",
|
||||
"hardwareConcurrency": 4,
|
||||
"maxTouchPoints": 0
|
||||
},
|
||||
"screen": {
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"colorDepth": 30,
|
||||
"availWidth": 1920,
|
||||
"availHeight": 1011
|
||||
},
|
||||
"webgl": {
|
||||
"unmaskedVendor": "NVIDIA Corporation",
|
||||
"unmaskedRenderer": "NVIDIA GeForce GTX 480, or similar"
|
||||
},
|
||||
"speechVoices": [
|
||||
"Alex:en-US:local",
|
||||
"Alice:it-IT:local",
|
||||
"Alva:sv-SE:local",
|
||||
"Amelie:fr-CA:local",
|
||||
"Anna:de-DE:local",
|
||||
"Carmit:he-IL:local",
|
||||
"Damayanti:id-ID:local",
|
||||
"Daniel:en-GB:local",
|
||||
"Diego:es-AR:local",
|
||||
"Ellen:nl-BE:local",
|
||||
"Fiona:en-scotland:local",
|
||||
"Fred:en-US:local",
|
||||
"Ioana:ro-RO:local",
|
||||
"Joana:pt-PT:local",
|
||||
"Jorge:es-ES:local",
|
||||
"Juan:es-MX:local",
|
||||
"Kanya:th-TH:local",
|
||||
"Karen:en-AU:local",
|
||||
"Kyoko:ja-JP:local",
|
||||
"Laura:sk-SK:local",
|
||||
"Lekha:hi-IN:local",
|
||||
"Luca:it-IT:local",
|
||||
"Luciana:pt-BR:local",
|
||||
"Maged:ar-SA:local",
|
||||
"Mariska:hu-HU:local",
|
||||
"Mei-Jia:zh-TW:local",
|
||||
"Melina:el-GR:local",
|
||||
"Milena:ru-RU:local",
|
||||
"Moira:en-IE:local",
|
||||
"Monica:es-ES:local",
|
||||
"Nora:nb-NO:local",
|
||||
"Paulina:es-MX:local",
|
||||
"Rishi:en-IN:local",
|
||||
"Samantha:en-US:local",
|
||||
"Sara:da-DK:local",
|
||||
"Satu:fi-FI:local",
|
||||
"Sin-ji:zh-HK:local",
|
||||
"Tessa:en-ZA:local",
|
||||
"Thomas:fr-FR:local",
|
||||
"Ting-Ting:zh-CN:local",
|
||||
"Veena:en-IN:local",
|
||||
"Victoria:en-US:local",
|
||||
"Xander:nl-NL:local",
|
||||
"Yelda:tr-TR:local",
|
||||
"Yuna:ko-KR:local",
|
||||
"Yuri:ru-RU:local",
|
||||
"Zosia:pl-PL:local",
|
||||
"Zuzana:cs-CZ:local"
|
||||
]
|
||||
},
|
||||
{
|
||||
"navigator": {
|
||||
"userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:147.0) Gecko/20100101 Firefox/147.0",
|
||||
@@ -2982,28 +2894,6 @@
|
||||
"Microsoft Huihui Desktop - Chinese (Simplified):zh-CN:local"
|
||||
]
|
||||
},
|
||||
{
|
||||
"navigator": {
|
||||
"userAgent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0",
|
||||
"platform": "Win32",
|
||||
"hardwareConcurrency": 2,
|
||||
"maxTouchPoints": 0
|
||||
},
|
||||
"screen": {
|
||||
"width": 1366,
|
||||
"height": 768,
|
||||
"colorDepth": 24,
|
||||
"availWidth": 1366,
|
||||
"availHeight": 728
|
||||
},
|
||||
"webgl": {
|
||||
"unmaskedVendor": "Google Inc. (Intel)",
|
||||
"unmaskedRenderer": "ANGLE (Intel, Intel 945GM Direct3D11 vs_4_0 ps_4_0)"
|
||||
},
|
||||
"speechVoices": [
|
||||
"Microsoft Anna - English (United States):en-US:local"
|
||||
]
|
||||
},
|
||||
{
|
||||
"navigator": {
|
||||
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:148.0) Gecko/20100101 Firefox/148.0",
|
||||
@@ -3188,45 +3078,6 @@
|
||||
"Microsoft Zira Desktop - English (United States):en-US:local"
|
||||
]
|
||||
},
|
||||
{
|
||||
"navigator": {
|
||||
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:148.0) Gecko/20100101 Firefox/148.0",
|
||||
"platform": "Win32",
|
||||
"hardwareConcurrency": 8,
|
||||
"maxTouchPoints": 0
|
||||
},
|
||||
"screen": {
|
||||
"width": 1680,
|
||||
"height": 1050,
|
||||
"colorDepth": 24,
|
||||
"availWidth": 1680,
|
||||
"availHeight": 1010
|
||||
},
|
||||
"webgl": {
|
||||
"unmaskedVendor": "Google Inc. (NVIDIA)",
|
||||
"unmaskedRenderer": "ANGLE (NVIDIA, NVIDIA GeForce 8800 GTX Direct3D11 vs_4_0 ps_4_0), or similar"
|
||||
},
|
||||
"speechVoices": [
|
||||
"Microsoft David - English (United States):en-US:local",
|
||||
"Microsoft James - English (Australia):en-AU:local",
|
||||
"Microsoft Linda - English (Canada):en-CA:local",
|
||||
"Microsoft Richard - English (Canada):en-CA:local",
|
||||
"Microsoft George - English (United Kingdom):en-GB:local",
|
||||
"Microsoft Hazel - English (United Kingdom):en-GB:local",
|
||||
"Microsoft Susan - English (United Kingdom):en-GB:local",
|
||||
"Microsoft Sean - English (Ireland):en-IE:local",
|
||||
"Microsoft Heera - English (India):en-IN:local",
|
||||
"Microsoft Ravi - English (India):en-IN:local",
|
||||
"Microsoft Catherine - English (Australia):en-AU:local",
|
||||
"Microsoft Mark - English (United States):en-US:local",
|
||||
"Microsoft Zira - English (United States):en-US:local",
|
||||
"Microsoft George - English (Great Britain):en-GB:local",
|
||||
"Microsoft Sarah Mobile - English (Great Britain):en-GB:local",
|
||||
"Microsoft Hazel Desktop - English (Great Britain):en-GB:local",
|
||||
"Microsoft David Desktop - English (United States):en-US:local",
|
||||
"Microsoft Zira Desktop - English (United States):en-US:local"
|
||||
]
|
||||
},
|
||||
{
|
||||
"navigator": {
|
||||
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:148.0) Gecko/20100101 Firefox/148.0",
|
||||
@@ -4360,25 +4211,6 @@
|
||||
"unmaskedRenderer": "NVIDIA GeForce GTX 980, or similar"
|
||||
}
|
||||
},
|
||||
{
|
||||
"navigator": {
|
||||
"userAgent": "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:148.0) Gecko/20100101 Firefox/148.0",
|
||||
"platform": "Linux x86_64",
|
||||
"hardwareConcurrency": 8,
|
||||
"maxTouchPoints": 0
|
||||
},
|
||||
"screen": {
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"colorDepth": 24,
|
||||
"availWidth": 1920,
|
||||
"availHeight": 1010
|
||||
},
|
||||
"webgl": {
|
||||
"unmaskedVendor": "NVIDIA Corporation",
|
||||
"unmaskedRenderer": "GeForce GTX 480, or similar"
|
||||
}
|
||||
},
|
||||
{
|
||||
"navigator": {
|
||||
"userAgent": "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0",
|
||||
|
||||
@@ -6,11 +6,12 @@ import secrets
|
||||
import unicodedata
|
||||
from dataclasses import asdict, dataclass, is_dataclass
|
||||
from pathlib import Path
|
||||
from random import Random, choice, randint, randrange, random, sample, shuffle
|
||||
from random import Random, choice, randint, randrange
|
||||
from typing import Any, Dict, FrozenSet, List, Optional, Set, Tuple
|
||||
|
||||
from camoufox._warnings import FallbackWarning
|
||||
from camoufox.ip import valid_ipv4, validate_ip
|
||||
from camoufox.pkgman import load_yaml
|
||||
from camoufox.webgl import sample_webgl
|
||||
|
||||
# Load the fpgen mapping file
|
||||
FPGEN_DATA = load_yaml('fpgen.yml')
|
||||
@@ -399,7 +400,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 +426,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
|
||||
|
||||
@@ -595,44 +602,6 @@ def _generate_random_font_subset(
|
||||
return result
|
||||
|
||||
|
||||
# OS voice lists loaded from voices.json, parsed into "Name:lang:type" tuples.
|
||||
_OS_VOICES_CACHE: Optional[Dict[str, List[Tuple[str, str, str]]]] = None
|
||||
|
||||
|
||||
def _load_os_voices() -> Dict[str, List[Tuple[str, str, str]]]:
|
||||
"""Load OS voice lists from voices.json as (name, lang, type) tuples.
|
||||
|
||||
Each entry is "Name:lang:type" (type is "local" or "remote"). Voice names
|
||||
may contain parens/commas but not colons, so a last-two-colons split is
|
||||
safe.
|
||||
"""
|
||||
global _OS_VOICES_CACHE
|
||||
if _OS_VOICES_CACHE is not None:
|
||||
return _OS_VOICES_CACHE
|
||||
voices_path = os.path.join(os.path.dirname(__file__), 'voices.json')
|
||||
with open(voices_path, 'rb') as f:
|
||||
import orjson
|
||||
raw = orjson.loads(f.read())
|
||||
_OS_VOICES_CACHE = {}
|
||||
for os_key, entries in raw.items():
|
||||
parsed: List[Tuple[str, str, str]] = []
|
||||
for entry in entries:
|
||||
last = entry.rfind(':')
|
||||
if last < 0:
|
||||
continue
|
||||
vtype = entry[last + 1:]
|
||||
before = entry[:last]
|
||||
langsep = before.rfind(':')
|
||||
if langsep < 0:
|
||||
continue
|
||||
lang = before[langsep + 1:]
|
||||
name = before[:langsep]
|
||||
if name and lang:
|
||||
parsed.append((name, lang, vtype))
|
||||
_OS_VOICES_CACHE[os_key] = parsed
|
||||
return _OS_VOICES_CACHE
|
||||
|
||||
|
||||
# Essential speech voices per OS that must always be included in subsets
|
||||
_ESSENTIAL_VOICES_MACOS = [
|
||||
'Samantha', 'Alex', 'Fred', 'Victoria', 'Karen', 'Daniel',
|
||||
@@ -1305,8 +1274,8 @@ def draw_media_devices(os_key: str, seed: Optional[int]) -> Dict[str, Any]:
|
||||
|
||||
# -- WebGL <-> screen coherence (#729) ---------------------------------------
|
||||
#
|
||||
# BrowserForge picks navigator/screen; the GPU is drawn separately from
|
||||
# webgl_data.db weighted only by OS. Nothing ties the two together, so the
|
||||
# fpgen picks navigator/screen; the GPU is drawn separately, weighted only by
|
||||
# OS (camoufox.webgl). Nothing ties the two together, so the
|
||||
# synthetic path can emit pairs no real machine ships -- a discrete GPU behind
|
||||
# a 1024x600 netbook panel. Consistency checks (Pixelscan, Fingerprint.com)
|
||||
# read that as masking even when every individual value is plausible alone.
|
||||
@@ -1344,8 +1313,8 @@ _SOFTWARE_RENDERERS: Tuple[str, ...] = (
|
||||
'Generic Renderer',
|
||||
)
|
||||
|
||||
# Discrete NVIDIA, plus the AMD R5/R7/R9/RX/Vega bucket. Everything else in
|
||||
# webgl_data.db reaches down into netbook territory and gets no floor at all:
|
||||
# Discrete NVIDIA, plus the AMD R5/R7/R9/RX/Vega bucket. Every other GPU
|
||||
# Firefox reports reaches down into netbook territory and gets no floor at all:
|
||||
# the "Intel(R) HD Graphics" bucket swallows the GMA 3150 netbook chipset,
|
||||
# "Radeon HD 3200 Graphics" is Gecko's catch-all for a bare "AMD"/"Radeon"
|
||||
# (the C-50/E-350 netbook APUs included), and Apple silicon drives arbitrary
|
||||
@@ -1457,57 +1426,6 @@ def gpu_screen_is_plausible(
|
||||
return width * height > _NETBOOK_MAX_PIXELS
|
||||
|
||||
|
||||
def sample_webgl_for_screen(
|
||||
target_os: str,
|
||||
width: Optional[int] = None,
|
||||
height: Optional[int] = None,
|
||||
attempts: int = 32,
|
||||
seed: Optional[int] = None,
|
||||
) -> Dict[str, str]:
|
||||
"""Sample a WebGL profile that is coherent with the screen already chosen.
|
||||
|
||||
Rejection sampling, so the GPU keeps webgl_data.db's real OS-weighted
|
||||
distribution -- we only drop draws that contradict the screen. The screen
|
||||
itself is left alone on purpose: it has already been reconciled with the
|
||||
real display and the window box (clamp_screen_to_display,
|
||||
fix_screen_no_taskbar, clamp_window_dimensions, clamp_window_position),
|
||||
and widening it here to flatter the GPU would push a headful window back
|
||||
off the monitor it is drawn on (#499).
|
||||
|
||||
Software rasterisers are the one exception to keeping the pool's rate: a
|
||||
draw that lands on llvmpipe / WARP / SwiftShader is resampled, so they
|
||||
never present as the GPU (see below). That does cost fidelity -- the corpus
|
||||
records them at ~1.5%, because real users do run without working drivers --
|
||||
but "no consumer machine reports llvmpipe" is a live, standard check on a
|
||||
string every fingerprint script already reads, so the trade is worth it.
|
||||
Reviewed against the JS-detectability bar on 2026-09-17 and kept.
|
||||
|
||||
Falls back to that first draw when the pool holds nothing coherent, so an
|
||||
unusual screen degrades to today's behaviour rather than raising.
|
||||
"""
|
||||
# A software rasteriser (llvmpipe / SwiftShader / WARP) as the presented
|
||||
# GPU is what every consumer-hardware check flags first ("no consumer
|
||||
# machine reports llvmpipe" -- sundial, measured 2026-09-14), so the draw
|
||||
# never settles on one: keep drawing until a hardware renderer that fits
|
||||
# the screen comes up, and only fall back to the first draw if the pool
|
||||
# holds nothing better.
|
||||
first = sample_webgl(target_os, seed=seed)
|
||||
renderer = first.get('webGl:renderer')
|
||||
if not is_software_renderer(renderer) and gpu_screen_is_plausible(renderer, width, height):
|
||||
return first
|
||||
|
||||
fallback = None if is_software_renderer(renderer) else first
|
||||
for attempt in range(attempts - 1):
|
||||
candidate = sample_webgl(target_os, seed=None if seed is None else seed + 1 + attempt)
|
||||
renderer = candidate.get('webGl:renderer')
|
||||
if is_software_renderer(renderer):
|
||||
continue
|
||||
if gpu_screen_is_plausible(renderer, width, height):
|
||||
return candidate
|
||||
fallback = fallback or candidate
|
||||
return fallback or first
|
||||
|
||||
|
||||
def _select_presets_file(ff_version: Optional[Any] = None) -> Path:
|
||||
"""Pick the bundled-presets file appropriate for a given Firefox version.
|
||||
|
||||
@@ -1682,12 +1600,8 @@ def from_preset(preset: Dict, ff_version: Optional[str] = None, salt: Optional[i
|
||||
if webgl.get('unmaskedRenderer'):
|
||||
config['webGl:renderer'] = webgl['unmaskedRenderer']
|
||||
|
||||
# Generate unique random seeds per launch (1 to 2^32-1, excluding 0 which is a no-op in C++)
|
||||
# fonts:spacing_seed stays 0 (off): glyph-advance perturbation produces text
|
||||
# widths no real machine emits (see launch_options in utils.py).
|
||||
config['fonts:spacing_seed'] = 0
|
||||
# Generate a unique audio seed per launch (1 to 2^32-1, excluding 0 which is a no-op in C++)
|
||||
config['audio:seed'] = randint(1, 4_294_967_295) # nosec
|
||||
config['canvas:seed'] = randint(1, 4_294_967_295) # nosec
|
||||
|
||||
if preset.get('timezone'):
|
||||
config['timezone'] = preset['timezone']
|
||||
@@ -1702,10 +1616,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, {
|
||||
@@ -1717,7 +1637,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
|
||||
@@ -1736,9 +1662,7 @@ def _build_init_script(values: Dict[str, Any]) -> str:
|
||||
lines = ['(function(v) {', ' var w = window;']
|
||||
|
||||
setters = [
|
||||
('fontSpacingSeed', 'setFontSpacingSeed', '{val}'),
|
||||
('audioFingerprintSeed', 'setAudioFingerprintSeed', '{val}'),
|
||||
('canvasSeed', 'setCanvasSeed', '{val}'),
|
||||
('navigatorPlatform', 'setNavigatorPlatform', '{val}'),
|
||||
('navigatorOscpu', 'setNavigatorOscpu', '{val}'),
|
||||
('navigatorUserAgent', 'setNavigatorUserAgent', '{val}'),
|
||||
@@ -1783,8 +1707,10 @@ def _build_init_script(values: Dict[str, Any]) -> str:
|
||||
# WebRTC IP
|
||||
ip = values.get('webrtcIP')
|
||||
if ip:
|
||||
validate_ip(ip)
|
||||
fn_name = 'setWebRTCIPv4' if valid_ipv4(ip) else 'setWebRTCIPv6'
|
||||
lines.append(
|
||||
f' if (typeof w.setWebRTCIPv4 === "function") w.setWebRTCIPv4({_json.dumps(ip)});'
|
||||
f' if (typeof w.{fn_name} === "function") w.{fn_name}({_json.dumps(ip)});'
|
||||
)
|
||||
else:
|
||||
lines.append(
|
||||
@@ -1827,7 +1753,7 @@ def generate_context_fingerprint(
|
||||
Generate fingerprint values for a single per-context identity.
|
||||
Returns a dict with init_script (JS string) and context_options (Playwright options).
|
||||
|
||||
By default, uses BrowserForge for infinite unique synthetic fingerprints.
|
||||
By default, fpgen generates a unique synthetic fingerprint.
|
||||
Pass a preset dict to use a real fingerprint preset instead.
|
||||
|
||||
Parameters:
|
||||
@@ -1838,8 +1764,7 @@ def generate_context_fingerprint(
|
||||
normalize_locale() and injected into config. Also sets
|
||||
context_options['locale'] for Playwright.
|
||||
config_overrides: Dict of CAMOU_CONFIG keys to override after config
|
||||
is built but before init_script is rendered. Useful for disabling
|
||||
perturbation (e.g. {'fonts:spacing_seed': 0}).
|
||||
is built but before init_script is rendered (e.g. {'audio:seed': 7}).
|
||||
"""
|
||||
if preset is not None:
|
||||
# Use real fingerprint preset
|
||||
@@ -1856,9 +1781,7 @@ def generate_context_fingerprint(
|
||||
_salt = identity_salt()
|
||||
|
||||
# Add seeds (the generator doesn't produce these)
|
||||
config.setdefault('fonts:spacing_seed', 0) # perturbation off; see utils.launch_options
|
||||
config.setdefault('audio:seed', randint(1, 4_294_967_295)) # nosec
|
||||
config.setdefault('canvas:seed', randint(1, 4_294_967_295)) # nosec
|
||||
|
||||
# Determine target OS from platform for font/voice generation
|
||||
plat = config.get('navigator.platform', '')
|
||||
@@ -1868,21 +1791,27 @@ def generate_context_fingerprint(
|
||||
elif 'Linux' in plat or 'linux' in plat:
|
||||
os_name = 'linux'
|
||||
|
||||
# Add fonts (BrowserForge doesn't generate these)
|
||||
# Add fonts (fpgen.yml does not map these yet)
|
||||
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 (BrowserForge doesn't generate these)
|
||||
# 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 BrowserForge didn't provide it
|
||||
# Derive oscpu if the fingerprint didn't provide it
|
||||
if 'navigator.oscpu' not in config:
|
||||
plat = config.get('navigator.platform', '')
|
||||
if plat == 'MacIntel':
|
||||
@@ -1892,8 +1821,11 @@ def generate_context_fingerprint(
|
||||
elif 'Linux' in plat or 'linux' in plat:
|
||||
config['navigator.oscpu'] = 'Linux x86_64'
|
||||
|
||||
# Sample WebGL vendor/renderer from database (BrowserForge doesn't generate these)
|
||||
# Draw the GPU and its WebGL data (fpgen.yml does not map these)
|
||||
if not config.get('webGl:vendor') or not config.get('webGl:renderer'):
|
||||
# Not at the top: camoufox.webgl imports this module.
|
||||
from .webgl import sample_webgl_for_screen
|
||||
|
||||
_os_map = {'macos': 'mac', 'linux': 'lin', 'windows': 'win'}
|
||||
_target_os = _os_map.get(os or '', None)
|
||||
if not _target_os:
|
||||
@@ -1904,21 +1836,18 @@ def generate_context_fingerprint(
|
||||
_target_os = 'lin'
|
||||
else:
|
||||
_target_os = 'mac'
|
||||
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')
|
||||
)
|
||||
webgl_fp.pop('webGl2Enabled', None)
|
||||
config.update(webgl_fp)
|
||||
except Exception:
|
||||
pass
|
||||
# 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')
|
||||
)
|
||||
webgl_fp.pop('webGl2Enabled')
|
||||
config.update(webgl_fp)
|
||||
|
||||
# Build source dicts from BrowserForge config for init_values
|
||||
# Build source dicts from the fingerprint config for init_values
|
||||
nav = {
|
||||
'platform': config.get('navigator.platform'),
|
||||
'hardwareConcurrency': config.get('navigator.hardwareConcurrency'),
|
||||
@@ -1953,9 +1882,7 @@ def generate_context_fingerprint(
|
||||
|
||||
# Build the values dict for the init script (works for both paths)
|
||||
init_values: Dict[str, Any] = {
|
||||
'fontSpacingSeed': config.get('fonts:spacing_seed'),
|
||||
'audioFingerprintSeed': config.get('audio:seed'),
|
||||
'canvasSeed': config.get('canvas:seed'),
|
||||
'navigatorPlatform': nav.get('platform'),
|
||||
'navigatorOscpu': config.get('navigator.oscpu'),
|
||||
'navigatorUserAgent': config.get('navigator.userAgent'),
|
||||
@@ -2012,7 +1939,7 @@ def _cast_to_properties(
|
||||
ff_version: Optional[str] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Casts Browserforge fingerprints to Camoufox config properties.
|
||||
Casts a generated fingerprint to Camoufox config properties.
|
||||
"""
|
||||
for key, data in bf_dict.items():
|
||||
# Ignore non-truthy values
|
||||
|
||||
@@ -1,297 +0,0 @@
|
||||
"""What fonts are actually installed on THIS machine.
|
||||
|
||||
Camoufox ships its own font bundle and spoofs the claimed OS's font list from
|
||||
it, so nothing in a normal launch depends on the host's fonts. This module is
|
||||
the diagnostic half: it answers "what does this machine really have", which is
|
||||
what a user needs to know when deciding whether a claimed identity is plausible
|
||||
here, and what `camoufox fonts` reports.
|
||||
|
||||
Enumeration is per platform because the authoritative list is:
|
||||
|
||||
Linux fontconfig (`fc-list`), which is what Gecko itself asks
|
||||
Windows the font registry, plus the per-user font directory that
|
||||
Windows 10 1809 and later install into without touching HKLM
|
||||
macOS the three font directories CoreText reads
|
||||
|
||||
The result is cached under the Camoufox cache directory and invalidated by the
|
||||
(path, mtime, size) of every directory scanned, so a call after the first costs
|
||||
a handful of stats.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from typing import Any, Dict, Iterable, List, Optional, Set, Tuple
|
||||
|
||||
CACHE_VERSION = 1
|
||||
FONT_EXT = (".ttf", ".otf", ".ttc", ".otc", ".dfont", ".pfb")
|
||||
|
||||
# fc-list can be slow on a machine with thousands of fonts, and a launch should
|
||||
# never hang on it.
|
||||
PROBE_TIMEOUT_S = 20
|
||||
|
||||
|
||||
def host_os() -> str:
|
||||
"""'linux', 'macos' or 'windows' -- the keys the font data is filed under."""
|
||||
return {"Linux": "linux", "Darwin": "macos", "Windows": "windows"}.get(
|
||||
platform.system(), "linux")
|
||||
|
||||
|
||||
def normalise(name: str) -> str:
|
||||
"""The form two family names are compared in.
|
||||
|
||||
Case and surrounding space only. Nothing clever: "Segoe UI" and
|
||||
"Segoe UI Semibold" are different families to DirectWrite and must stay
|
||||
different here, or a host with one would be credited with the other.
|
||||
"""
|
||||
return " ".join(name.split()).lower()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# per-platform enumeration
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _linux_font_dirs() -> List[str]:
|
||||
dirs = ["/usr/share/fonts", "/usr/local/share/fonts",
|
||||
os.path.expanduser("~/.local/share/fonts"),
|
||||
os.path.expanduser("~/.fonts")]
|
||||
return [d for d in dirs if os.path.isdir(d)]
|
||||
|
||||
|
||||
def _windows_font_dirs() -> List[str]:
|
||||
dirs = [os.path.join(os.environ.get("WINDIR", r"C:\Windows"), "Fonts")]
|
||||
local = os.environ.get("LOCALAPPDATA")
|
||||
if local:
|
||||
dirs.append(os.path.join(local, "Microsoft", "Windows", "Fonts"))
|
||||
return [d for d in dirs if os.path.isdir(d)]
|
||||
|
||||
|
||||
def _macos_font_dirs() -> List[str]:
|
||||
dirs = ["/System/Library/Fonts", "/Library/Fonts",
|
||||
os.path.expanduser("~/Library/Fonts"),
|
||||
"/System/Library/Fonts/Supplemental"]
|
||||
return [d for d in dirs if os.path.isdir(d)]
|
||||
|
||||
|
||||
def font_dirs() -> List[str]:
|
||||
return {"linux": _linux_font_dirs, "windows": _windows_font_dirs,
|
||||
"macos": _macos_font_dirs}[host_os()]()
|
||||
|
||||
|
||||
def _from_fc_list() -> Optional[Set[str]]:
|
||||
"""Ask fontconfig, which is the same source Gecko uses on Linux.
|
||||
|
||||
Deliberately run with the caller's own environment stripped of Camoufox's
|
||||
FONTCONFIG_FILE: that variable points at the bundle, and the question here
|
||||
is what the HOST has.
|
||||
"""
|
||||
env = dict(os.environ)
|
||||
env.pop("FONTCONFIG_FILE", None)
|
||||
env.pop("FONTCONFIG_PATH", None)
|
||||
try:
|
||||
out = subprocess.run(["fc-list", "--format", "%{family}\\n"],
|
||||
capture_output=True, timeout=PROBE_TIMEOUT_S,
|
||||
env=env)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None
|
||||
if out.returncode != 0:
|
||||
return None
|
||||
families: Set[str] = set()
|
||||
for line in out.stdout.decode("utf-8", "replace").splitlines():
|
||||
# fontconfig prints every alias of a family, comma separated.
|
||||
for name in line.split(","):
|
||||
name = name.strip()
|
||||
if name:
|
||||
families.add(name)
|
||||
return families or None
|
||||
|
||||
|
||||
def _from_windows_registry() -> Optional[Set[str]]:
|
||||
"""The font registry: what GDI and DirectWrite enumerate."""
|
||||
try:
|
||||
import winreg # noqa: WPS433
|
||||
except ImportError:
|
||||
return None
|
||||
families: Set[str] = set()
|
||||
keys = [(winreg.HKEY_LOCAL_MACHINE,
|
||||
r"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts"),
|
||||
(winreg.HKEY_CURRENT_USER,
|
||||
r"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts")]
|
||||
for root, path in keys:
|
||||
try:
|
||||
handle = winreg.OpenKey(root, path)
|
||||
except OSError:
|
||||
continue
|
||||
try:
|
||||
index = 0
|
||||
while True:
|
||||
try:
|
||||
name, _value, _kind = winreg.EnumValue(handle, index)
|
||||
except OSError:
|
||||
break
|
||||
index += 1
|
||||
# Values read "Segoe UI Bold (TrueType)" or
|
||||
# "MS Gothic & MS PGothic & MS UI Gothic (TrueType)".
|
||||
for part in name.split("(")[0].split("&"):
|
||||
part = part.strip()
|
||||
if part:
|
||||
families.add(part)
|
||||
finally:
|
||||
winreg.CloseKey(handle)
|
||||
return families or None
|
||||
|
||||
|
||||
def _from_font_files(dirs: Iterable[str], limit: int = 0) -> Set[str]:
|
||||
"""Read the name table of every font file. The portable fallback."""
|
||||
try:
|
||||
from fontTools.ttLib import TTCollection, TTFont # noqa: WPS433
|
||||
except ImportError:
|
||||
return set()
|
||||
|
||||
families: Set[str] = set()
|
||||
seen = 0
|
||||
for directory in dirs:
|
||||
for root, _subdirs, names in os.walk(directory):
|
||||
for name in sorted(names):
|
||||
if not name.lower().endswith(FONT_EXT):
|
||||
continue
|
||||
path = os.path.join(root, name)
|
||||
seen += 1
|
||||
if limit and seen > limit:
|
||||
return families
|
||||
try:
|
||||
if name.lower().endswith((".ttc", ".otc")):
|
||||
with TTCollection(path, lazy=True) as collection:
|
||||
fonts = list(collection.fonts)
|
||||
else:
|
||||
fonts = [TTFont(path, lazy=True,
|
||||
ignoreDecompileErrors=True)]
|
||||
except Exception:
|
||||
continue
|
||||
for font in fonts:
|
||||
try:
|
||||
table = font["name"] if "name" in font else None
|
||||
if table is None:
|
||||
continue
|
||||
for name_id in (16, 1):
|
||||
value = table.getDebugName(name_id)
|
||||
if value:
|
||||
families.add(value.strip())
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
font.close()
|
||||
except Exception:
|
||||
pass
|
||||
return families
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# the cached inventory
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _signature(dirs: Iterable[str]) -> str:
|
||||
"""Cheap invalidation: the directories and their mtimes.
|
||||
|
||||
Installing or removing a font changes the mtime of the directory it is in,
|
||||
which is enough -- and costs four stats instead of reading 3000 name
|
||||
tables.
|
||||
"""
|
||||
parts = []
|
||||
for directory in sorted(dirs):
|
||||
try:
|
||||
stat = os.stat(directory)
|
||||
parts.append("%s:%d:%d" % (directory, int(stat.st_mtime), stat.st_size))
|
||||
except OSError:
|
||||
parts.append("%s:-" % directory)
|
||||
return "|".join(parts)
|
||||
|
||||
|
||||
def _cache_path() -> Optional[str]:
|
||||
try:
|
||||
from .pkgman import INSTALL_DIR # noqa: WPS433
|
||||
base = str(INSTALL_DIR)
|
||||
except Exception:
|
||||
base = os.path.join(os.path.expanduser("~"), ".cache", "camoufox")
|
||||
try:
|
||||
os.makedirs(os.path.join(base, "fontcache"), exist_ok=True)
|
||||
except OSError:
|
||||
return None
|
||||
return os.path.join(base, "fontcache", "host-fonts.json")
|
||||
|
||||
|
||||
def installed_families(refresh: bool = False,
|
||||
use_cache: bool = True) -> Dict[str, Any]:
|
||||
"""Every font family this machine has, with where the answer came from.
|
||||
|
||||
Returns {"os", "families": [...], "source", "dirs", "seconds", "cached"}.
|
||||
"""
|
||||
dirs = font_dirs()
|
||||
signature = _signature(dirs)
|
||||
path = _cache_path() if use_cache else None
|
||||
|
||||
if path and not refresh and os.path.exists(path):
|
||||
try:
|
||||
with open(path, "rb") as fh:
|
||||
cached = json.loads(fh.read())
|
||||
if (cached.get("version") == CACHE_VERSION
|
||||
and cached.get("signature") == signature):
|
||||
cached["cached"] = True
|
||||
return cached
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
|
||||
started = time.time()
|
||||
families: Optional[Set[str]] = None
|
||||
source = "files"
|
||||
system = host_os()
|
||||
if system == "linux":
|
||||
families = _from_fc_list()
|
||||
source = "fontconfig"
|
||||
elif system == "windows":
|
||||
families = _from_windows_registry()
|
||||
source = "registry"
|
||||
if not families:
|
||||
families = _from_font_files(dirs)
|
||||
source = "files"
|
||||
|
||||
result = {
|
||||
"version": CACHE_VERSION,
|
||||
"signature": signature,
|
||||
"os": system,
|
||||
"source": source,
|
||||
"dirs": dirs,
|
||||
"families": sorted(families),
|
||||
"seconds": round(time.time() - started, 3),
|
||||
"cached": False,
|
||||
}
|
||||
if path:
|
||||
try:
|
||||
tmp = path + ".tmp%d" % os.getpid()
|
||||
with open(tmp, "w") as fh:
|
||||
json.dump(result, fh)
|
||||
os.replace(tmp, path)
|
||||
except OSError:
|
||||
pass
|
||||
return result
|
||||
|
||||
|
||||
def family_index(inventory: Optional[Dict[str, Any]] = None) -> Set[str]:
|
||||
"""The installed families, normalised for comparison."""
|
||||
inventory = inventory or installed_families()
|
||||
return {normalise(name) for name in inventory.get("families", [])}
|
||||
|
||||
|
||||
def partition(claimed: Iterable[str],
|
||||
inventory: Optional[Dict[str, Any]] = None
|
||||
) -> Tuple[List[str], List[str]]:
|
||||
"""Split the families an identity claims into (on this host, not on it)."""
|
||||
index = family_index(inventory)
|
||||
real, missing = [], []
|
||||
for name in claimed:
|
||||
(real if normalise(name) in index else missing).append(name)
|
||||
return real, missing
|
||||
@@ -2,9 +2,11 @@
|
||||
#
|
||||
# fpgen (scrapfly/fingerprint-generator) replaced BrowserForge/Apify as the
|
||||
# generator. The field set below is the one BrowserForge used to supply -- the
|
||||
# rest of what fpgen carries (fonts, voices, WebGL parameters, system styles,
|
||||
# permissions, RTC capabilities, audio) is NOT mapped here yet; Camoufox still
|
||||
# draws those from its own catalogues. Wiring those through is the follow-up.
|
||||
# rest of what fpgen carries (fonts, voices, system styles, permissions, RTC
|
||||
# capabilities, audio) is NOT mapped here yet; Camoufox still draws those from
|
||||
# its own catalogues. Wiring those through is the follow-up. WebGL is not
|
||||
# mapped here either: camoufox/webgl.py draws it from fpgen, conditioned on
|
||||
# the GPU, so presets get their own GPU's parameters too.
|
||||
#
|
||||
# Shape differences from browserforge.yml:
|
||||
# - window geometry is its own `window` node, not part of `screen`
|
||||
@@ -18,13 +20,10 @@ navigator:
|
||||
# fpgen's UAs trail the current release; the version is rewritten to the
|
||||
# Camoufox Firefox version in _cast_to_properties.
|
||||
userAgent: navigator.userAgent
|
||||
appCodeName: navigator.appCodeName
|
||||
appName: navigator.appName
|
||||
appVersion: navigator.appVersion
|
||||
oscpu: navigator.oscpu
|
||||
platform: navigator.platform
|
||||
hardwareConcurrency: navigator.hardwareConcurrency
|
||||
product: navigator.product
|
||||
# Never override productSub (#105)
|
||||
# deviceMemory is not in Firefox, and fpgen reports the string "undefined"
|
||||
# Locale is handled separately (locale:*)
|
||||
@@ -62,7 +61,6 @@ window:
|
||||
# bottom edge land (see BROWSER_CHROME_HEIGHT in coherence.py).
|
||||
screenX: window.screenX
|
||||
screenY: window.screenY
|
||||
pageYOffset: screen.pageYOffset
|
||||
# devicePixelRatio is not mapped: any value but 1 is a spoofing tell unless
|
||||
# the whole geometry is scaled with it.
|
||||
|
||||
|
||||
@@ -119,7 +119,7 @@ def get_mmdb_path(ip_version: str = 'ipv4', config: Optional[Dict] = None) -> Pa
|
||||
|
||||
def geoip_allowed() -> None:
|
||||
"""
|
||||
Checks if the geoip2 module is available
|
||||
Checks if the maxminddb module is available
|
||||
"""
|
||||
if not ALLOW_GEOIP:
|
||||
raise NotInstalledGeoIPExtra(
|
||||
|
||||
@@ -75,6 +75,34 @@ def validate_ip(ip: str) -> None:
|
||||
raise InvalidIP(f"Invalid IP address: {ip}")
|
||||
|
||||
|
||||
def proxy_exit_geo(proxy: str) -> Tuple[str, str]:
|
||||
"""
|
||||
The exit IP of `proxy` and that IP's timezone, looked up through the proxy.
|
||||
Raises InvalidIP when the lookup fails: a context that silently kept the
|
||||
host's WebRTC IP and timezone behind a proxy would be a leak.
|
||||
"""
|
||||
try:
|
||||
resp = requests.get(
|
||||
"http://ip-api.com/json?fields=status,message,query,timezone",
|
||||
proxies=Proxy.as_requests_proxy(proxy),
|
||||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except requests.RequestException as exception:
|
||||
raise InvalidIP(f"{PROXY_LOOKUP_FAILED}: {exception}") from exception
|
||||
if data.get("status") != "success" or not data.get("timezone"):
|
||||
raise InvalidIP(f"{PROXY_LOOKUP_FAILED}: {data.get('message') or data}")
|
||||
validate_ip(data["query"])
|
||||
return data["query"], data["timezone"]
|
||||
|
||||
|
||||
PROXY_LOOKUP_FAILED = (
|
||||
"Could not look up the proxy's exit IP and timezone. Pass webrtc_ip and "
|
||||
"timezone_id explicitly to skip the lookup"
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def public_ip(proxy: Optional[str] = None) -> str:
|
||||
"""
|
||||
|
||||
@@ -123,14 +123,6 @@ def latest_per_build(versions: List[Dict]) -> List[Dict]:
|
||||
)
|
||||
|
||||
|
||||
def get_cached_repo_names() -> List[str]:
|
||||
"""
|
||||
Get list of repo names in cache
|
||||
"""
|
||||
cache = load_repo_cache()
|
||||
return [r['name'] for r in cache.get('repos', [])]
|
||||
|
||||
|
||||
def get_repo_name(github_repo: str) -> str:
|
||||
"""
|
||||
Get display name for a repo from repos.yml, lowercased
|
||||
|
||||
@@ -2,7 +2,6 @@ import hashlib
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
@@ -393,13 +392,6 @@ class Version:
|
||||
version=version_data.get('version'),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def is_supported_path(path: Path) -> bool:
|
||||
"""
|
||||
Check if the version at the given path is supported
|
||||
"""
|
||||
return Version.from_path(path) >= VERSION_MIN
|
||||
|
||||
@staticmethod
|
||||
def build_minmax() -> Tuple['Version', 'Version']:
|
||||
return Version(build=CONSTRAINTS.MIN_VERSION), Version(build=CONSTRAINTS.MAX_VERSION)
|
||||
@@ -629,31 +621,6 @@ class CamoufoxFetcher(GitHubDownloader):
|
||||
rprint(f'Downloading package: {url}')
|
||||
return webdl(url, buffer=file)
|
||||
|
||||
def extract_zip(self, zip_file: DownloadBuffer) -> None:
|
||||
"""
|
||||
Extract a zip file to the installation directory
|
||||
"""
|
||||
rprint(f'Extracting Camoufox: {INSTALL_DIR}')
|
||||
unzip(zip_file, str(INSTALL_DIR))
|
||||
|
||||
@staticmethod
|
||||
def cleanup() -> bool:
|
||||
"""
|
||||
Clean up the old installation
|
||||
"""
|
||||
if INSTALL_DIR.exists():
|
||||
rprint(f'Cleaning up cache: {INSTALL_DIR}')
|
||||
shutil.rmtree(INSTALL_DIR)
|
||||
return True
|
||||
return False
|
||||
|
||||
def set_version(self) -> None:
|
||||
"""
|
||||
Write version.json to INSTALL_DIR
|
||||
"""
|
||||
with open(INSTALL_DIR / 'version.json', 'wb') as f:
|
||||
f.write(orjson.dumps({'version': self.version, 'build': self.build}))
|
||||
|
||||
def install(self, replace: bool = False) -> None:
|
||||
"""
|
||||
Download and install camoufox to a versioned subdirectory
|
||||
|
||||
@@ -8,6 +8,7 @@ from playwright._impl._driver import compute_driver_executable
|
||||
|
||||
from camoufox.pkgman import LOCAL_DATA
|
||||
from camoufox.utils import launch_options
|
||||
from camoufox.virtdisplay import VirtualDisplay
|
||||
|
||||
LAUNCH_SCRIPT: Path = LOCAL_DATA / "launchServer.js"
|
||||
|
||||
@@ -60,7 +61,20 @@ def launch_server(**kwargs) -> NoReturn:
|
||||
)
|
||||
kwargs.pop(unsupported, None)
|
||||
|
||||
config = launch_options(**kwargs)
|
||||
virtual_display = None
|
||||
if kwargs.get('headless') == 'virtual':
|
||||
virtual_display = VirtualDisplay(debug=kwargs.get('debug'))
|
||||
kwargs['virtual_display'] = virtual_display.get()
|
||||
kwargs['headless'] = False
|
||||
try:
|
||||
_serve(launch_options(**kwargs))
|
||||
finally:
|
||||
if virtual_display:
|
||||
virtual_display.kill()
|
||||
|
||||
|
||||
def _serve(config: Dict[str, Any]) -> NoReturn:
|
||||
"""Run launchServer.js with `config` until the Node process exits."""
|
||||
nodejs = get_nodejs()
|
||||
|
||||
data = orjson.dumps(to_camel_case_dict(config))
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import json as _json
|
||||
import urllib.request
|
||||
from typing import Any, Dict, List, Optional, Union, overload
|
||||
from urllib.parse import urlparse
|
||||
from typing import Any, Dict, Optional, Tuple, Union, overload
|
||||
|
||||
from playwright.sync_api import (
|
||||
Browser,
|
||||
@@ -14,6 +11,7 @@ from typing_extensions import Literal
|
||||
from camoufox.virtdisplay import VirtualDisplay
|
||||
|
||||
from .fingerprints import generate_context_fingerprint
|
||||
from .ip import Proxy, proxy_exit_geo
|
||||
from .utils import (
|
||||
attach_no_viewport_default,
|
||||
attach_stock_media_defaults,
|
||||
@@ -156,27 +154,9 @@ def NewBrowser(
|
||||
cpu_affinity.restore(pid, previous)
|
||||
|
||||
|
||||
def _proxy_url_with_creds(proxy: Dict[str, str]) -> str:
|
||||
"""Builds a proxy URL string with embedded credentials."""
|
||||
parsed = urlparse(proxy.get("server", ""))
|
||||
user = proxy.get("username", "")
|
||||
pwd = proxy.get("password", "")
|
||||
if user and pwd:
|
||||
return f"{parsed.scheme}://{user}:{pwd}@{parsed.netloc}"
|
||||
return proxy.get("server", "")
|
||||
|
||||
|
||||
def _resolve_proxy_geo(proxy: Dict[str, str]) -> Dict[str, Optional[str]]:
|
||||
"""Queries ip-api.com through the proxy for the exit IP and timezone."""
|
||||
proxy_url = _proxy_url_with_creds(proxy)
|
||||
handler = urllib.request.ProxyHandler({"http": proxy_url, "https": proxy_url})
|
||||
opener = urllib.request.build_opener(handler)
|
||||
try:
|
||||
with opener.open("http://ip-api.com/json?fields=query,timezone", timeout=10) as resp:
|
||||
data = _json.loads(resp.read())
|
||||
return {"ip": data.get("query") or None, "timezone": data.get("timezone") or None}
|
||||
except Exception:
|
||||
return {"ip": None, "timezone": None}
|
||||
def _resolve_proxy_geo(proxy: Dict[str, str]) -> Tuple[str, str]:
|
||||
"""The proxy's exit IP and timezone."""
|
||||
return proxy_exit_geo(Proxy(**proxy).as_string())
|
||||
|
||||
|
||||
def NewContext(
|
||||
@@ -193,27 +173,31 @@ def NewContext(
|
||||
"""
|
||||
Creates a new browser context with a unique fingerprint identity.
|
||||
|
||||
Each context gets its own real fingerprint preset
|
||||
with unique seeds for audio, canvas, and font spacing noise. All values are applied
|
||||
Each context gets its own identity (navigator, screen, WebGL, fonts, voices),
|
||||
drawn by fpgen unless a preset is given, with its own audio noise seed. All values are applied
|
||||
via addInitScript so they self-destruct before page scripts can detect them.
|
||||
|
||||
Parameters:
|
||||
browser: A Browser instance from NewBrowser or Camoufox.
|
||||
preset: A specific fingerprint preset dict to use. If None, picks randomly.
|
||||
os: Target OS for preset selection ("windows", "macos", "linux").
|
||||
ff_version: Firefox version string for UA patching.
|
||||
webrtc_ip: IPv4 address to spoof for WebRTC ICE candidates.
|
||||
preset: A fingerprint preset dict to use. If None, fpgen draws a new identity.
|
||||
os: Target OS for the drawn identity ("windows", "macos", "linux").
|
||||
ff_version: Firefox major version to claim in the UA. Defaults to the browser's own.
|
||||
webrtc_ip: IPv4 or IPv6 address to spoof for WebRTC ICE candidates.
|
||||
proxy: Per-context proxy (Playwright format: {"server": "...", "username": "...", "password": "..."}).
|
||||
Unless webrtc_ip and timezone_id are both given, they are looked up from the
|
||||
proxy's exit IP; InvalidIP is raised if that lookup fails.
|
||||
geolocation: Per-context geolocation ({"latitude": float, "longitude": float}).
|
||||
**context_kwargs: Additional Playwright new_context() options.
|
||||
"""
|
||||
# The drawn UA carries fpgen's Firefox version, which must not disagree with
|
||||
# the browser the page is actually talking to.
|
||||
ff_version = ff_version or browser.version.split('.', 1)[0]
|
||||
|
||||
# Auto-derive WebRTC IP and timezone from proxy's exit IP when not explicitly provided
|
||||
if proxy and (not webrtc_ip or "timezone_id" not in context_kwargs):
|
||||
geo = _resolve_proxy_geo(proxy)
|
||||
if not webrtc_ip:
|
||||
webrtc_ip = geo["ip"]
|
||||
if "timezone_id" not in context_kwargs and geo["timezone"]:
|
||||
context_kwargs["timezone_id"] = geo["timezone"]
|
||||
exit_ip, timezone = _resolve_proxy_geo(proxy)
|
||||
webrtc_ip = webrtc_ip or exit_ip
|
||||
context_kwargs.setdefault("timezone_id", timezone)
|
||||
|
||||
fp = generate_context_fingerprint(preset=preset, os=os, ff_version=ff_version, webrtc_ip=webrtc_ip)
|
||||
|
||||
|
||||
+45
-78
@@ -8,7 +8,6 @@ from os import environ
|
||||
from os.path import abspath
|
||||
from pathlib import Path
|
||||
from pprint import pprint
|
||||
from random import randint
|
||||
from typing import Any, Dict, List, Literal, Optional, Tuple, Union
|
||||
|
||||
import numpy as np
|
||||
@@ -23,7 +22,7 @@ from .exceptions import (
|
||||
InvalidPropertyType,
|
||||
NonFirefoxFingerprint,
|
||||
)
|
||||
from .fingerprints import Screen, from_fpgen, from_preset, generate_fingerprint, get_random_preset, _generate_random_font_subset, _generate_random_voice_subset, fix_navigator_arch, fix_hardware_concurrency, identity_salt, identity_seed, fix_screen_no_taskbar, clamp_screen_to_display, clamp_window_dimensions, clamp_window_position, raise_screen_to_modern_floor, sample_webgl_for_screen, set_media_devices_defaults, WINDOWS_11_MARKER_FONTS
|
||||
from .fingerprints import Screen, from_fpgen, from_preset, generate_fingerprint, get_random_preset, _generate_random_font_subset, _generate_random_voice_subset, fix_navigator_arch, fix_hardware_concurrency, identity_salt, identity_seed, fix_screen_no_taskbar, clamp_screen_to_display, clamp_window_dimensions, clamp_window_position, raise_screen_to_modern_floor, set_media_devices_defaults, WINDOWS_11_MARKER_FONTS
|
||||
from . import coherence
|
||||
from .geolocation import geoip_allowed, get_geolocation
|
||||
from .ip import Proxy, public_ip, valid_ipv4, valid_ipv6
|
||||
@@ -41,8 +40,8 @@ from .pkgman import (
|
||||
launch_path,
|
||||
)
|
||||
from .virtdisplay import VirtualDisplay
|
||||
from ._warnings import LeakWarning
|
||||
from .webgl import sample_webgl
|
||||
from ._warnings import FallbackWarning, LeakWarning
|
||||
from .webgl import sample_webgl_for_screen, webgl_for_gpu
|
||||
|
||||
ListOrString: TypeAlias = Union[Tuple[str, ...], List[str], str]
|
||||
|
||||
@@ -309,7 +308,7 @@ def get_env_vars(
|
||||
}
|
||||
os_dir = directory_map.get(user_agent_os, user_agent_os)
|
||||
|
||||
# v150+ uses "fontconfig/" (matching the Go launcher); older bundles shipped "fontconfigs/".
|
||||
# v150+ uses "fontconfig/"; older bundles shipped "fontconfigs/".
|
||||
def _bundle_path(*parts: str) -> str:
|
||||
if path:
|
||||
return str(path.parent.joinpath(*parts))
|
||||
@@ -515,15 +514,6 @@ def check_valid_os(os: ListOrString) -> None:
|
||||
raise InvalidOS(f"Camoufox does not support the OS: '{os}'")
|
||||
|
||||
|
||||
def _clean_locals(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Gets the launch options from the locals of the function.
|
||||
"""
|
||||
del data['playwright']
|
||||
del data['persistent_context']
|
||||
return data
|
||||
|
||||
|
||||
def merge_into(target: Dict[str, Any], source: Dict[str, Any]) -> None:
|
||||
"""
|
||||
Merges new keys/values from the source dictionary into the target dictionary.
|
||||
@@ -568,7 +558,7 @@ def warn_manual_config(config: Dict[str, Any]) -> None:
|
||||
"""
|
||||
# Manual locale setting
|
||||
if is_domain_set(
|
||||
config, 'navigator.language', 'navigator.languages', 'headers.Accept-Language', 'locale:'
|
||||
config, 'navigator.language', 'headers.Accept-Language', 'locale:'
|
||||
):
|
||||
LeakWarning.warn('locale', False)
|
||||
# Manual geolocation and timezone setting
|
||||
@@ -585,6 +575,8 @@ def warn_manual_config(config: Dict[str, Any]) -> None:
|
||||
# CSS pointer media queries and the TouchEvent interfaces.
|
||||
if is_domain_set(config, 'navigator.maxTouchPoints'):
|
||||
LeakWarning.warn('max_touch_points', False)
|
||||
if config.get('instantAnimations'):
|
||||
LeakWarning.warn('instant_animations', False)
|
||||
# Manual screen/window setting
|
||||
if is_domain_set(config, 'screen.', 'window.', 'document.body.'):
|
||||
LeakWarning.warn('viewport', False)
|
||||
@@ -595,8 +587,6 @@ _WINDOW_DIM_KEYS = (
|
||||
'window.outerHeight',
|
||||
'window.innerWidth',
|
||||
'window.innerHeight',
|
||||
'document.body.clientWidth',
|
||||
'document.body.clientHeight',
|
||||
)
|
||||
|
||||
|
||||
@@ -894,16 +884,16 @@ def launch_options(
|
||||
If not provided, a random fingerprint will be generated based on the provided
|
||||
`os` & `screen` constraints.
|
||||
fingerprint_preset (Optional[Union[bool, Dict[str, Any]]]):
|
||||
Opt into using real fingerprint presets instead of BrowserForge.
|
||||
Opt into using real fingerprint presets instead of fpgen.
|
||||
Pass `True` to use a random bundled preset, or pass a preset dict directly.
|
||||
By default (None), BrowserForge is used for infinite unique fingerprints.
|
||||
By default (None), fpgen generates a unique fingerprint.
|
||||
ff_version (Optional[int]):
|
||||
Firefox version to use. Defaults to the current Camoufox version.
|
||||
To prevent leaks, only use this for special cases.
|
||||
headless (Optional[bool]):
|
||||
Whether to run the browser in headless mode. Defaults to False.
|
||||
Note: If you are running linux, passing headless='virtual' to Camoufox & AsyncCamoufox
|
||||
will use Xvfb.
|
||||
Note: If you are running linux, passing headless='virtual' to Camoufox, AsyncCamoufox
|
||||
or launch_server will use Xvfb.
|
||||
main_world_eval (Optional[bool]):
|
||||
Whether to enable running scripts in the main world.
|
||||
To use this, prepend "mw:" to the script: page.evaluate("mw:" + script).
|
||||
@@ -931,7 +921,7 @@ def launch_options(
|
||||
debug (Optional[bool]):
|
||||
Prints the config being sent to Camoufox.
|
||||
virtual_display (Optional[str]):
|
||||
Virtual display number. Ex: ':99'. This is handled by Camoufox & AsyncCamoufox.
|
||||
Virtual display number. Ex: ':99'. This is handled by Camoufox, AsyncCamoufox and launch_server.
|
||||
pin_cpu_cores (Optional[bool]):
|
||||
Pin the browser to navigator.hardwareConcurrency cores
|
||||
(Linux/Windows) so the fingerprint's own core count can be kept:
|
||||
@@ -941,6 +931,8 @@ def launch_options(
|
||||
which is equally coherent, just less diverse.
|
||||
webgl_config (Optional[Tuple[str, str]]):
|
||||
Use a specific WebGL vendor/renderer pair. Passed as a tuple of (vendor, renderer).
|
||||
The pair must be one fpgen has recorded from Firefox on `os`
|
||||
(camoufox.webgl.firefox_gpus); any other raises ValueError.
|
||||
**launch_options (Dict[str, Any]):
|
||||
Additional Firefox launch options.
|
||||
"""
|
||||
@@ -1004,7 +996,7 @@ def launch_options(
|
||||
_user_set_dnt = 'navigator.doNotTrack' in config
|
||||
_user_set_gpc = 'navigator.globalPrivacyControl' in config
|
||||
_user_set_accept_encoding = 'headers.Accept-Encoding' in config
|
||||
_user_set_noise_seeds = {k for k in ('audio:seed', 'canvas:seed') if k in config}
|
||||
_user_set_audio_seed = 'audio:seed' in config
|
||||
|
||||
# The salt that makes every seeded draw belong to this identity (see
|
||||
# fingerprints.identity_salt): stable when the caller pinned the identity
|
||||
@@ -1045,10 +1037,10 @@ def launch_options(
|
||||
# Generate a fingerprint
|
||||
_used_preset = False
|
||||
if fingerprint is not None:
|
||||
# User passed a custom BrowserForge fingerprint
|
||||
# User passed a custom fingerprint
|
||||
if not i_know_what_im_doing:
|
||||
check_custom_fingerprint(fingerprint)
|
||||
elif fingerprint_preset is not None:
|
||||
elif fingerprint_preset:
|
||||
# User opted into real fingerprint presets
|
||||
if isinstance(fingerprint_preset, dict):
|
||||
preset = fingerprint_preset
|
||||
@@ -1156,7 +1148,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
|
||||
@@ -1277,23 +1273,15 @@ def launch_options(
|
||||
if not _user_set_accept_encoding:
|
||||
config.pop('headers.Accept-Encoding', None)
|
||||
|
||||
# Set random seeds for fingerprint noise (per launch)
|
||||
# Glyph-advance perturbation is OFF by default (seed 0): it moves every
|
||||
# measured text width off the value the same font produces on a real
|
||||
# machine (measured 2026-09-14: +1 px per ~100 glyphs, fractional deltas
|
||||
# on every measureText), which is a fingerprint no stock Firefox emits.
|
||||
# Pass fonts:spacing_seed explicitly to opt back in.
|
||||
set_into(config, 'fonts:spacing_seed', 0)
|
||||
# audio/canvas noise seeds follow the identity: a returning "same device"
|
||||
# must reproduce its audio and canvas hashes (#442/#765). Derived, not
|
||||
# equal, so the two streams differ; never 0 (0 disables the noise).
|
||||
# A preset draws its own random seeds; they are replaced here too so a
|
||||
# pinned preset reproduces them, but a seed the caller set is kept.
|
||||
_ident = identity_seed(config, _identity_salt)
|
||||
if 'audio:seed' not in _user_set_noise_seeds:
|
||||
# The audio noise seed follows the identity: a returning "same device" must
|
||||
# reproduce its audio hash (#442/#765). Never 0 (0 disables the noise). A
|
||||
# preset draws its own random seed; it is replaced here too so a pinned
|
||||
# preset reproduces it, but a seed the caller set is kept. There is no
|
||||
# canvas seed: the browser adds no canvas noise (#528), and no glyph-spacing
|
||||
# noise either (ci/tribal-rules.yml: no-glyph-spacing-noise).
|
||||
if not _user_set_audio_seed:
|
||||
_ident = identity_seed(config, _identity_salt)
|
||||
config['audio:seed'] = ((_ident * 2654435761 + 97) & 0xFFFFFFFF) or 1
|
||||
if 'canvas:seed' not in _user_set_noise_seeds:
|
||||
config['canvas:seed'] = ((_ident * 40503 + 12345) & 0xFFFFFFFF) or 1
|
||||
|
||||
# Set geolocation
|
||||
if geoip:
|
||||
@@ -1392,10 +1380,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
|
||||
@@ -1430,46 +1421,22 @@ def launch_options(
|
||||
LeakWarning.warn('disable_coop', i_know_what_im_doing)
|
||||
firefox_user_prefs['browser.tabs.remote.useCrossOriginOpenerPolicy'] = False
|
||||
|
||||
# Allow allow_webgl parameter for backwards compatibility
|
||||
if block_webgl or launch_options.pop('allow_webgl', True) is False:
|
||||
if block_webgl:
|
||||
firefox_user_prefs['webgl.disabled'] = True
|
||||
LeakWarning.warn('block_webgl', i_know_what_im_doing)
|
||||
else:
|
||||
# If the user has provided a specific WebGL vendor/renderer pair, use it
|
||||
# A pair the caller named, or the preset's own GPU, keeps its name and
|
||||
# gets that device's recorded parameters. webgl_for_gpu raises for a GPU
|
||||
# fpgen has never seen: the caller asked for something that does not exist.
|
||||
if webgl_config:
|
||||
webgl_fp = sample_webgl(target_os, *webgl_config, seed=identity_seed(config, _identity_salt))
|
||||
webgl_fp = webgl_for_gpu(target_os, *webgl_config, seed=identity_seed(config, _identity_salt))
|
||||
elif config.get('webGl:vendor') and config.get('webGl:renderer'):
|
||||
# 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 33 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.
|
||||
webgl_fp = sample_webgl_for_screen(
|
||||
target_os, config.get('screen.width'), config.get('screen.height'),
|
||||
seed=identity_seed(config, _identity_salt),
|
||||
)
|
||||
# merge_into does not overwrite keys that are already set, and the
|
||||
# preset set these two. Drop them, or the page would read the
|
||||
# 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)
|
||||
webgl_fp = webgl_for_gpu(
|
||||
target_os, config['webGl:vendor'], config['webGl:renderer'],
|
||||
seed=identity_seed(config, _identity_salt),
|
||||
)
|
||||
else:
|
||||
# Synthetic path: keep the GPU coherent with the screen BrowserForge
|
||||
# Synthetic path: keep the GPU coherent with the screen fpgen
|
||||
# already picked. Sampling the two independently yields pairs no
|
||||
# real machine ships -- a discrete desktop GPU behind a 1024x600
|
||||
# panel -- which consistency checks read as masking (#729).
|
||||
@@ -1493,7 +1460,7 @@ def launch_options(
|
||||
# Every identity passes the whole-identity checks, whatever built it: a
|
||||
# generated fingerprint, a bundled preset, or a config the caller wrote.
|
||||
# The pools are sampled independently -- navigator and screen from the
|
||||
# generator, GPU from webgl_data.db, fonts and voices from their own
|
||||
# generator, GPU from fpgen's WebGL records, fonts and voices from their own
|
||||
# catalogues -- so a machine that never existed can be assembled from parts
|
||||
# that are each fine on their own. See coherence.py.
|
||||
_incoherent = coherence.apply(config, target_os)
|
||||
|
||||
@@ -1,382 +0,0 @@
|
||||
{
|
||||
"mac": [
|
||||
"Albert:en-US:local",
|
||||
"Alex:en-US:local",
|
||||
"Alice:it-IT:local",
|
||||
"Alva:sv-SE:local",
|
||||
"Aman:en-IN:local",
|
||||
"Amira:ms-MY:local",
|
||||
"Anna:de-DE:local",
|
||||
"Aru:kk-KZ:local",
|
||||
"Bad News:en-US:local",
|
||||
"Bahh:en-US:local",
|
||||
"Bells:en-US:local",
|
||||
"Boing:en-US:local",
|
||||
"Bubbles:en-US:local",
|
||||
"Carmit:he-IL:local",
|
||||
"Cellos:en-US:local",
|
||||
"Damayanti:id-ID:local",
|
||||
"Daniel:en-GB:local",
|
||||
"Daria:bg-BG:local",
|
||||
"Diego:es-AR:local",
|
||||
"Eddy (English (UK)):en-GB:local",
|
||||
"Eddy (English (US)):en-US:local",
|
||||
"Eddy (Finnish (Finland)):fi-FI:local",
|
||||
"Eddy (French (Canada)):fr-CA:local",
|
||||
"Eddy (French (France)):fr-FR:local",
|
||||
"Eddy (German (Germany)):de-DE:local",
|
||||
"Eddy (Italian (Italy)):it-IT:local",
|
||||
"Eddy (Portuguese (Brazil)):pt-BR:local",
|
||||
"Eddy (Spanish (Mexico)):es-MX:local",
|
||||
"Eddy (Spanish (Spain)):es-ES:local",
|
||||
"Eddy (Chinese (China mainland)):zh-CN:local",
|
||||
"Eddy (Chinese (Taiwan)):zh-TW:local",
|
||||
"Eddy (Japanese (Japan)):ja-JP:local",
|
||||
"Eddy (Korean (South Korea)):ko-KR:local",
|
||||
"Ellen:nl-BE:local",
|
||||
"Fiona:en-scotland:local",
|
||||
"Flo (English (UK)):en-GB:local",
|
||||
"Flo (English (US)):en-US:local",
|
||||
"Flo (Finnish (Finland)):fi-FI:local",
|
||||
"Flo (French (Canada)):fr-CA:local",
|
||||
"Flo (French (France)):fr-FR:local",
|
||||
"Flo (German (Germany)):de-DE:local",
|
||||
"Flo (Italian (Italy)):it-IT:local",
|
||||
"Flo (Portuguese (Brazil)):pt-BR:local",
|
||||
"Flo (Spanish (Mexico)):es-MX:local",
|
||||
"Flo (Spanish (Spain)):es-ES:local",
|
||||
"Flo (Chinese (China mainland)):zh-CN:local",
|
||||
"Flo (Chinese (Taiwan)):zh-TW:local",
|
||||
"Flo (Japanese (Japan)):ja-JP:local",
|
||||
"Flo (Korean (South Korea)):ko-KR:local",
|
||||
"Fred:en-US:local",
|
||||
"Geeta:te-IN:local",
|
||||
"Good News:en-US:local",
|
||||
"Grandma (English (UK)):en-GB:local",
|
||||
"Grandma (English (US)):en-US:local",
|
||||
"Grandma (Finnish (Finland)):fi-FI:local",
|
||||
"Grandma (French (Canada)):fr-CA:local",
|
||||
"Grandma (French (France)):fr-FR:local",
|
||||
"Grandma (German (Germany)):de-DE:local",
|
||||
"Grandma (Italian (Italy)):it-IT:local",
|
||||
"Grandma (Portuguese (Brazil)):pt-BR:local",
|
||||
"Grandma (Spanish (Mexico)):es-MX:local",
|
||||
"Grandma (Spanish (Spain)):es-ES:local",
|
||||
"Grandma (Chinese (China mainland)):zh-CN:local",
|
||||
"Grandma (Chinese (Taiwan)):zh-TW:local",
|
||||
"Grandma (Japanese (Japan)):ja-JP:local",
|
||||
"Grandma (Korean (South Korea)):ko-KR:local",
|
||||
"Grandpa (English (UK)):en-GB:local",
|
||||
"Grandpa (English (US)):en-US:local",
|
||||
"Grandpa (Finnish (Finland)):fi-FI:local",
|
||||
"Grandpa (French (Canada)):fr-CA:local",
|
||||
"Grandpa (French (France)):fr-FR:local",
|
||||
"Grandpa (German (Germany)):de-DE:local",
|
||||
"Grandpa (Italian (Italy)):it-IT:local",
|
||||
"Grandpa (Portuguese (Brazil)):pt-BR:local",
|
||||
"Grandpa (Spanish (Mexico)):es-MX:local",
|
||||
"Grandpa (Spanish (Spain)):es-ES:local",
|
||||
"Grandpa (Chinese (China mainland)):zh-CN:local",
|
||||
"Grandpa (Chinese (Taiwan)):zh-TW:local",
|
||||
"Grandpa (Japanese (Japan)):ja-JP:local",
|
||||
"Grandpa (Korean (South Korea)):ko-KR:local",
|
||||
"Ioana:ro-RO:local",
|
||||
"Jacques:fr-FR:local",
|
||||
"Jester:en-US:local",
|
||||
"Joana:pt-PT:local",
|
||||
"Jorge:es-ES:local",
|
||||
"Juan:es-MX:local",
|
||||
"Junior:en-US:local",
|
||||
"Kanya:th-TH:local",
|
||||
"Karen:en-AU:local",
|
||||
"Kathy:en-US:local",
|
||||
"Kyoko:ja-JP:local",
|
||||
"Lana:hr-HR:local",
|
||||
"Laura:sk-SK:local",
|
||||
"Lekha:hi-IN:local",
|
||||
"Lesya:uk-UA:local",
|
||||
"Linh:vi-VN:local",
|
||||
"Luca:it-IT:local",
|
||||
"Luciana:pt-BR:local",
|
||||
"Majed:ar-001:local",
|
||||
"Meijia:zh-TW:local",
|
||||
"Melina:el-GR:local",
|
||||
"Milena:ru-RU:local",
|
||||
"Moira:en-IE:local",
|
||||
"Montse:ca-ES:local",
|
||||
"Nora:nb-NO:local",
|
||||
"Ona:lt-LT:local",
|
||||
"Organ:en-US:local",
|
||||
"Paulina:es-MX:local",
|
||||
"Piya:bn-IN:local",
|
||||
"Ralph:en-US:local",
|
||||
"Reed (English (UK)):en-GB:local",
|
||||
"Reed (English (US)):en-US:local",
|
||||
"Reed (Finnish (Finland)):fi-FI:local",
|
||||
"Reed (French (Canada)):fr-CA:local",
|
||||
"Reed (German (Germany)):de-DE:local",
|
||||
"Reed (Italian (Italy)):it-IT:local",
|
||||
"Reed (Portuguese (Brazil)):pt-BR:local",
|
||||
"Reed (Spanish (Mexico)):es-MX:local",
|
||||
"Reed (Spanish (Spain)):es-ES:local",
|
||||
"Reed (Chinese (China mainland)):zh-CN:local",
|
||||
"Reed (Chinese (Taiwan)):zh-TW:local",
|
||||
"Reed (Japanese (Japan)):ja-JP:local",
|
||||
"Reed (Korean (South Korea)):ko-KR:local",
|
||||
"Rishi:en-IN:local",
|
||||
"Rocko (English (UK)):en-GB:local",
|
||||
"Rocko (English (US)):en-US:local",
|
||||
"Rocko (Finnish (Finland)):fi-FI:local",
|
||||
"Rocko (French (Canada)):fr-CA:local",
|
||||
"Rocko (French (France)):fr-FR:local",
|
||||
"Rocko (German (Germany)):de-DE:local",
|
||||
"Rocko (Italian (Italy)):it-IT:local",
|
||||
"Rocko (Portuguese (Brazil)):pt-BR:local",
|
||||
"Rocko (Spanish (Mexico)):es-MX:local",
|
||||
"Rocko (Spanish (Spain)):es-ES:local",
|
||||
"Rocko (Chinese (China mainland)):zh-CN:local",
|
||||
"Rocko (Chinese (Taiwan)):zh-TW:local",
|
||||
"Rocko (Japanese (Japan)):ja-JP:local",
|
||||
"Rocko (Korean (South Korea)):ko-KR:local",
|
||||
"Samantha:en-US:local",
|
||||
"Sandy (English (UK)):en-GB:local",
|
||||
"Sandy (English (US)):en-US:local",
|
||||
"Sandy (Finnish (Finland)):fi-FI:local",
|
||||
"Sandy (French (Canada)):fr-CA:local",
|
||||
"Sandy (French (France)):fr-FR:local",
|
||||
"Sandy (German (Germany)):de-DE:local",
|
||||
"Sandy (Italian (Italy)):it-IT:local",
|
||||
"Sandy (Portuguese (Brazil)):pt-BR:local",
|
||||
"Sandy (Spanish (Mexico)):es-MX:local",
|
||||
"Sandy (Spanish (Spain)):es-ES:local",
|
||||
"Sandy (Chinese (China mainland)):zh-CN:local",
|
||||
"Sandy (Chinese (Taiwan)):zh-TW:local",
|
||||
"Sandy (Japanese (Japan)):ja-JP:local",
|
||||
"Sandy (Korean (South Korea)):ko-KR:local",
|
||||
"Sara:da-DK:local",
|
||||
"Satu:fi-FI:local",
|
||||
"Shelley (English (UK)):en-GB:local",
|
||||
"Shelley (English (US)):en-US:local",
|
||||
"Shelley (Finnish (Finland)):fi-FI:local",
|
||||
"Shelley (French (Canada)):fr-CA:local",
|
||||
"Shelley (French (France)):fr-FR:local",
|
||||
"Shelley (German (Germany)):de-DE:local",
|
||||
"Shelley (Italian (Italy)):it-IT:local",
|
||||
"Shelley (Portuguese (Brazil)):pt-BR:local",
|
||||
"Shelley (Spanish (Mexico)):es-MX:local",
|
||||
"Shelley (Spanish (Spain)):es-ES:local",
|
||||
"Shelley (Chinese (China mainland)):zh-CN:local",
|
||||
"Shelley (Chinese (Taiwan)):zh-TW:local",
|
||||
"Shelley (Japanese (Japan)):ja-JP:local",
|
||||
"Shelley (Korean (South Korea)):ko-KR:local",
|
||||
"Sinji:zh-HK:local",
|
||||
"Soumya:kn-IN:local",
|
||||
"Superstar:en-US:local",
|
||||
"Tara:en-IN:local",
|
||||
"Tessa:en-ZA:local",
|
||||
"Thomas:fr-FR:local",
|
||||
"Tina:sl-SI:local",
|
||||
"Tingting:zh-CN:local",
|
||||
"Trinoids:en-US:local",
|
||||
"Vani:ta-IN:local",
|
||||
"Veena:en-IN:local",
|
||||
"Victoria:en-US:local",
|
||||
"Whisper:en-US:local",
|
||||
"Wobble:en-US:local",
|
||||
"Xander:nl-NL:local",
|
||||
"Yelda:tr-TR:local",
|
||||
"Yuna:ko-KR:local",
|
||||
"Yuri:ru-RU:local",
|
||||
"Zarvox:en-US:local",
|
||||
"Zosia:pl-PL:local",
|
||||
"Zuzana:cs-CZ:local"
|
||||
],
|
||||
"win": [
|
||||
"Microsoft David - English (United States):en-US:local",
|
||||
"Microsoft Mark - English (United States):en-US:local",
|
||||
"Microsoft Zira - English (United States):en-US:local",
|
||||
"Microsoft David Desktop - English (United States):en-US:local",
|
||||
"Microsoft Zira Desktop - English (United States):en-US:local",
|
||||
"Microsoft Hazel - English (United Kingdom):en-GB:local",
|
||||
"Microsoft Hazel Desktop - English (Great Britain):en-GB:local",
|
||||
"Microsoft George - English (United Kingdom):en-GB:local",
|
||||
"Microsoft Susan - English (United Kingdom):en-GB:local",
|
||||
"Microsoft Catherine - English (Australia):en-AU:local",
|
||||
"Microsoft James - English (Australia):en-AU:local",
|
||||
"Microsoft Linda - English (Canada):en-CA:local",
|
||||
"Microsoft Richard - English (Canada):en-CA:local",
|
||||
"Microsoft Sean - English (Ireland):en-IE:local",
|
||||
"Microsoft Heera - English (India):en-IN:local",
|
||||
"Microsoft Ravi - English (India):en-IN:local",
|
||||
"Microsoft Hedda - German (Germany):de-DE:local",
|
||||
"Microsoft Hedda Desktop - German:de-DE:local",
|
||||
"Microsoft Katja - German (Germany):de-DE:local",
|
||||
"Microsoft Stefan - German (Germany):de-DE:local",
|
||||
"Microsoft Hortense - French (France):fr-FR:local",
|
||||
"Microsoft Hortense Desktop - French:fr-FR:local",
|
||||
"Microsoft Julie - French (France):fr-FR:local",
|
||||
"Microsoft Paul - French (France):fr-FR:local",
|
||||
"Microsoft Helena - Spanish (Spain):es-ES:local",
|
||||
"Microsoft Helena Desktop - Spanish (Spain):es-ES:local",
|
||||
"Microsoft Laura - Spanish (Spain):es-ES:local",
|
||||
"Microsoft Pablo - Spanish (Spain):es-ES:local",
|
||||
"Microsoft Sabina - Spanish (Mexico):es-MX:local",
|
||||
"Microsoft Sabina Desktop - Spanish (Mexico):es-MX:local",
|
||||
"Microsoft Raul - Spanish (Mexico):es-MX:local",
|
||||
"Microsoft Cosimo - Italian (Italy):it-IT:local",
|
||||
"Microsoft Elsa - Italian (Italy):it-IT:local",
|
||||
"Microsoft Elsa Desktop - Italian (Italy):it-IT:local",
|
||||
"Microsoft Daniel - Portuguese (Brazil):pt-BR:local",
|
||||
"Microsoft Maria - Portuguese (Brazil):pt-BR:local",
|
||||
"Microsoft Maria Desktop - Portuguese(Brazil):pt-BR:local",
|
||||
"Microsoft Helia - Portuguese (Portugal):pt-PT:local",
|
||||
"Microsoft Huihui - Chinese (Simplified, PRC):zh-CN:local",
|
||||
"Microsoft Huihui Desktop - Chinese (Simplified):zh-CN:local",
|
||||
"Microsoft Kangkang - Chinese (Simplified, PRC):zh-CN:local",
|
||||
"Microsoft Yaoyao - Chinese (Simplified, PRC):zh-CN:local",
|
||||
"Microsoft Anna - English (United States):en-US:local",
|
||||
"Microsoft Frank - Dutch (Netherlands):nl-NL:local",
|
||||
"Microsoft Adam - Polish (Poland):pl-PL:local",
|
||||
"Microsoft Paulina - Polish (Poland):pl-PL:local",
|
||||
"Microsoft Paulina Desktop - Polish:pl-PL:local",
|
||||
"Microsoft Jakub - Czech (Czech Republic):cs-CZ:local",
|
||||
"Microsoft Stefanos - Greek (Greece):el-GR:local",
|
||||
"Microsoft Asaf - Hebrew (Israel):he-IL:local",
|
||||
"Microsoft Naayf - Arabic (Saudi):ar-SA:local",
|
||||
"Microsoft Tolga - Turkish (Turkey):tr-TR:local",
|
||||
"Microsoft Sarah Mobile - English (Great Britain):en-GB:local"
|
||||
],
|
||||
"lin": [
|
||||
"Afrikaans:af:local",
|
||||
"Amharic:am:local",
|
||||
"Aragonese:an:local",
|
||||
"Arabic:ar:local",
|
||||
"Assamese:as:local",
|
||||
"Azerbaijani:az:local",
|
||||
"Bashkir:ba:local",
|
||||
"Belarusian:be:local",
|
||||
"Bulgarian:bg:local",
|
||||
"Bengali:bn:local",
|
||||
"Bishnupriya Manipuri:bpy:local",
|
||||
"Bosnian:bs:local",
|
||||
"Catalan:ca:local",
|
||||
"Cherokee:chr-US-QAAA-X-WEST:local",
|
||||
"Chinese (Mandarin, latin as English):cmn:local",
|
||||
"Chinese (Mandarin, latin as Pinyin):cmn-LATN-PINYIN:local",
|
||||
"Czech:cs:local",
|
||||
"Chuvash:cv:local",
|
||||
"Welsh:cy:local",
|
||||
"Danish:da:local",
|
||||
"German:de:local",
|
||||
"Greek:el:local",
|
||||
"English (Caribbean):en-029:local",
|
||||
"English (Great Britain):en-GB:local",
|
||||
"English (Scotland):en-GB-SCOTLAND:local",
|
||||
"English (Lancaster):en-GB-X-GBCLAN:local",
|
||||
"English (West Midlands):en-GB-X-GBCWMD:local",
|
||||
"English (Received Pronunciation):en-GB-X-RP:local",
|
||||
"English (America):en-US:local",
|
||||
"English (America, New York City):en-US-NYC:local",
|
||||
"Esperanto:eo:local",
|
||||
"Spanish (Spain):es:local",
|
||||
"Spanish (Latin America):es-419:local",
|
||||
"Estonian:et:local",
|
||||
"Basque:eu:local",
|
||||
"Persian:fa:local",
|
||||
"Persian (Pinglish):fa-LATN:local",
|
||||
"Finnish:fi:local",
|
||||
"French (Belgium):fr-BE:local",
|
||||
"French (Switzerland):fr-CH:local",
|
||||
"French (France):fr-FR:local",
|
||||
"Gaelic (Irish):ga:local",
|
||||
"Gaelic (Scottish):gd:local",
|
||||
"Guarani:gn:local",
|
||||
"Greek (Ancient):grc:local",
|
||||
"Gujarati:gu:local",
|
||||
"Hakka Chinese:hak:local",
|
||||
"Hawaiian:haw:local",
|
||||
"Hebrew:he:local",
|
||||
"Hindi:hi:local",
|
||||
"Croatian:hr:local",
|
||||
"Haitian Creole:ht:local",
|
||||
"Hungarian:hu:local",
|
||||
"Armenian (East Armenia):hy:local",
|
||||
"Armenian (West Armenia):hyw:local",
|
||||
"Interlingua:ia:local",
|
||||
"Indonesian:id:local",
|
||||
"Ido:io:local",
|
||||
"Icelandic:is:local",
|
||||
"Italian:it:local",
|
||||
"Japanese:ja:local",
|
||||
"Lojban:jbo:local",
|
||||
"Georgian:ka:local",
|
||||
"Kazakh:kk:local",
|
||||
"Greenlandic:kl:local",
|
||||
"Kannada:kn:local",
|
||||
"Korean:ko:local",
|
||||
"Konkani:kok:local",
|
||||
"Kurdish:ku:local",
|
||||
"Kyrgyz:ky:local",
|
||||
"Latin:la:local",
|
||||
"Luxembourgish:lb:local",
|
||||
"Lingua Franca Nova:lfn:local",
|
||||
"Lithuanian:lt:local",
|
||||
"Latgalian:ltg:local",
|
||||
"Latvian:lv:local",
|
||||
"Māori:mi:local",
|
||||
"Macedonian:mk:local",
|
||||
"Malayalam:ml:local",
|
||||
"Marathi:mr:local",
|
||||
"Malay:ms:local",
|
||||
"Maltese:mt:local",
|
||||
"Myanmar (Burmese):my:local",
|
||||
"Norwegian Bokmål:nb:local",
|
||||
"Nahuatl (Classical):nci:local",
|
||||
"Nepali:ne:local",
|
||||
"Dutch:nl:local",
|
||||
"Nogai:nog:local",
|
||||
"Oromo:om:local",
|
||||
"Oriya:or:local",
|
||||
"Punjabi:pa:local",
|
||||
"Papiamento:pap:local",
|
||||
"Klingon:piqd:local",
|
||||
"Polish:pl:local",
|
||||
"Portuguese (Portugal):pt:local",
|
||||
"Portuguese (Brazil):pt-BR:local",
|
||||
"Pyash:py:local",
|
||||
"Lang_Belta:qdb:local",
|
||||
"Quechua:qu:local",
|
||||
"K'iche':quc:local",
|
||||
"Quenya:qya:local",
|
||||
"Romanian:ro:local",
|
||||
"Russian:ru:local",
|
||||
"Russian (Latvia):ru-LV:local",
|
||||
"Sindhi:sd:local",
|
||||
"Shan (Tai Yai):shn:local",
|
||||
"Sinhala:si:local",
|
||||
"Sindarin:sjn:local",
|
||||
"Slovak:sk:local",
|
||||
"Slovenian:sl:local",
|
||||
"Lule Saami:smj:local",
|
||||
"Albanian:sq:local",
|
||||
"Serbian:sr:local",
|
||||
"Swedish:sv:local",
|
||||
"Swahili:sw:local",
|
||||
"Tamil:ta:local",
|
||||
"Telugu:te:local",
|
||||
"Thai:th:local",
|
||||
"Turkmen:tk:local",
|
||||
"Setswana:tn:local",
|
||||
"Turkish:tr:local",
|
||||
"Tatar:tt:local",
|
||||
"Uyghur:ug:local",
|
||||
"Ukrainian:uk:local",
|
||||
"Urdu:ur:local",
|
||||
"Uzbek:uz:local",
|
||||
"Vietnamese (Northern):vi:local",
|
||||
"Vietnamese (Central):vi-VN-X-CENTRAL:local",
|
||||
"Vietnamese (Southern):vi-VN-X-SOUTH:local",
|
||||
"Chinese (Cantonese):yue:local",
|
||||
"Chinese (Cantonese, latin as Jyutping):yue:local"
|
||||
]
|
||||
}
|
||||
@@ -17,12 +17,11 @@ header-ua: >-
|
||||
viewport: >-
|
||||
Manually setting screen & window properties is not recommended.
|
||||
Screen dimensions are randomly generated within Camoufox
|
||||
based on the provided screen constraints. See here:
|
||||
https://github.com/daijro/camoufox/tree/main/pythonlib#browserforge-integration.
|
||||
based on the provided screen constraints.
|
||||
|
||||
custom_fingerprint: >-
|
||||
Passing your own fingerprint is not recommended.
|
||||
BrowserForge fingerprints are automatically generated within Camoufox
|
||||
Fingerprints are automatically generated within Camoufox
|
||||
based on the provided `os` and `screen` constraints.
|
||||
|
||||
proxy_without_geoip: >-
|
||||
@@ -37,6 +36,11 @@ no_region: >-
|
||||
Because you did not pass in a locale region, Camoufox will generate one for you.
|
||||
This can cause suspicion if your IP does not match your locale region.
|
||||
|
||||
instant_animations: >-
|
||||
instantAnimations makes every finite animation finish at once, so Playwright
|
||||
never waits on one. A page can see it: getComputedTiming() reports a duration
|
||||
of 0 where stock Firefox reports the real one.
|
||||
|
||||
block_webgl: >-
|
||||
Disabling WebGL is not recommended. Many WAFs will check if WebGL is enabled.
|
||||
|
||||
@@ -57,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,194 @@
|
||||
"""WebGL identities, drawn from the Firefox devices fpgen has recorded.
|
||||
|
||||
Everything a page can read from WebGL -- vendor, renderer, context attributes,
|
||||
extensions, parameters and shader precisions, for WebGL1 and WebGL2 -- comes
|
||||
from one recorded device. fpgen's `webgl` node is conditioned on the GPU, and
|
||||
`webgl2` on the GPU and the `webgl` chosen for it, so a WebGL2 limit never
|
||||
contradicts its WebGL1 counterpart.
|
||||
|
||||
Every draw takes one `random.Random`, in a fixed order (GPU, then webgl, then
|
||||
webgl2), so a seeded identity always presents the same device.
|
||||
"""
|
||||
|
||||
from functools import lru_cache
|
||||
from random import Random
|
||||
from typing import Any, Dict, FrozenSet, Optional, Tuple
|
||||
|
||||
import orjson
|
||||
|
||||
from .coherence import gpu_fits_os
|
||||
from .fingerprints import _FPGEN_OS, gpu_screen_is_plausible, is_software_renderer
|
||||
|
||||
# Extensions a release Firefox never exposes (draft extensions behind
|
||||
# webgl.enable-draft-extensions, or mobile-only): fpgen's corpus carries some
|
||||
# of them, and a spoofed list that names one is a tell on its own. Measured on
|
||||
# stock Firefox 152.0.4 on real Windows 11 (ANGLE D3D11) 2026-09-16: none of
|
||||
# these four are exposed. WEBGL_provoking_vertex is NOT in this set: stock
|
||||
# Firefox on Apple GPUs and on Windows does expose it.
|
||||
_NEVER_EXPOSED_EXTENSIONS = frozenset(
|
||||
{
|
||||
'WEBGL_multi_draw',
|
||||
'WEBGL_clip_cull_distance',
|
||||
'EXT_texture_norm16',
|
||||
'WEBGL_compressed_texture_etc1',
|
||||
}
|
||||
)
|
||||
|
||||
# OVR_multiview2 is a RELEASE extension whose availability depends on the
|
||||
# graphics backend. On Windows, Firefox renders WebGL through ANGLE's D3D11
|
||||
# backend, which implements multiview on every D3D11 GPU: stock 152.0.4 on a
|
||||
# Windows 11 host exposes it on WebGL2 (MAX_VIEWS_OVR=4, headless and headed),
|
||||
# and fpgen records it on ~99% of Windows WebGL2 devices -- filtering it there
|
||||
# was a leak. On Linux it depends on the host GL driver (stock on an NVIDIA box
|
||||
# does not expose it), and ClientWebGLContext::IsSupported answers from the
|
||||
# spoofed list without asking the host, so a Linux identity could advertise an
|
||||
# extension the GPU cannot back; it stays filtered off Windows.
|
||||
_HOST_DEPENDENT_EXTENSIONS = frozenset({'OVR_multiview2'})
|
||||
|
||||
# What Firefox reports under privacy.resistFingerprinting. A Camoufox identity
|
||||
# presents none of RFP's other changes (UTC, rounded windows), so "Mozilla"
|
||||
# beside them names a browser that does not exist.
|
||||
_RFP_RENDERER = 'Mozilla'
|
||||
|
||||
|
||||
def _filtered_extensions(target_os: str) -> FrozenSet[str]:
|
||||
if target_os == 'win':
|
||||
return _NEVER_EXPOSED_EXTENSIONS
|
||||
return _NEVER_EXPOSED_EXTENSIONS | _HOST_DEPENDENT_EXTENSIONS
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def _lookup_index(node: str) -> Dict[str, str]:
|
||||
"""fpgen's lookup index for every value of `node`, keyed by its JSON."""
|
||||
from fpgen.utils import _lookup_possibilities
|
||||
|
||||
return _lookup_possibilities(node, casefold=False)
|
||||
|
||||
|
||||
def _pin(node: str, value: Any) -> Tuple[str, str]:
|
||||
"""Evidence fixing `node` to exactly `value`.
|
||||
|
||||
A dict passed to fpgen as a condition is flattened into one condition per
|
||||
leaf, and each leaf replaces the node's evidence, so only the last one
|
||||
applies ("Mesa" and "AMD" Radeon HD 3200 would come back mixed). Pinning
|
||||
the value's own lookup index is exact.
|
||||
"""
|
||||
return node, _lookup_index(node)[orjson.dumps(value).decode()]
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def _trace(target: str, target_os: str, pinned: Tuple[Tuple[str, str], ...] = ()) -> Tuple[Any, ...]:
|
||||
"""fpgen's distribution of `target` for Firefox on `target_os`, in its order."""
|
||||
import fpgen
|
||||
|
||||
return tuple(
|
||||
fpgen.trace(
|
||||
target=target,
|
||||
browser='Firefox',
|
||||
os=_FPGEN_OS[target_os],
|
||||
__evidence__={node: {index} for node, index in pinned},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _choose(rng: Random, results: Tuple[Any, ...]) -> Any:
|
||||
return rng.choices(results, weights=[result.probability for result in results])[0].value
|
||||
|
||||
|
||||
def firefox_gpus(target_os: str) -> FrozenSet[Tuple[str, str]]:
|
||||
"""Every (vendor, renderer) that fpgen has seen Firefox report on this OS.
|
||||
|
||||
A GPU outside this set has no recorded WebGL parameters behind it, so an
|
||||
identity naming it could only borrow another device's.
|
||||
"""
|
||||
return frozenset(
|
||||
(result.value['vendor'], result.value['renderer'])
|
||||
for result in _trace('gpu', target_os.lower())
|
||||
)
|
||||
|
||||
|
||||
def _context_config(prefix: str, webgl: Dict[str, Any], target_os: str) -> Dict[str, Any]:
|
||||
blocked = _filtered_extensions(target_os)
|
||||
return {
|
||||
f'{prefix}:contextAttributes': webgl['contextAttributes'],
|
||||
f'{prefix}:supportedExtensions': [
|
||||
extension for extension in webgl['supportedExtensions'] if extension not in blocked
|
||||
],
|
||||
f'{prefix}:parameters': {pname: param['value'] for pname, param in webgl['params'].items()},
|
||||
f'{prefix}:shaderPrecisionFormats': {
|
||||
f"{entry['shaderType']},{entry['precisionType']}": entry['shaderPrecisionFormat']
|
||||
for entry in webgl['shaderPrecisionFormats']
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def to_config(webgl: Dict[str, Any], webgl2: Any, target_os: str) -> Dict[str, Any]:
|
||||
"""fpgen's `webgl` and `webgl2` values as Camoufox config keys.
|
||||
|
||||
`webgl2` is `[]` for a device without WebGL2.
|
||||
"""
|
||||
config = {
|
||||
'webGl:vendor': webgl['vendor'],
|
||||
'webGl:renderer': webgl['renderer'],
|
||||
**_context_config('webGl', webgl, target_os),
|
||||
'webGl2Enabled': bool(webgl2),
|
||||
}
|
||||
if webgl2:
|
||||
config.update(_context_config('webGl2', webgl2, target_os))
|
||||
# The values are fpgen's cached objects; the caller gets its own copy.
|
||||
return orjson.loads(orjson.dumps(config))
|
||||
|
||||
|
||||
def _webgl_config(target_os: str, gpu: Dict[str, str], rng: Random) -> Dict[str, Any]:
|
||||
gpu_pin = _pin('gpu', gpu)
|
||||
webgl = _choose(rng, _trace('webgl', target_os, (gpu_pin,)))
|
||||
webgl2 = _choose(rng, _trace('webgl2', target_os, (gpu_pin, _pin('webgl', webgl))))
|
||||
return to_config(webgl, webgl2, target_os)
|
||||
|
||||
|
||||
def webgl_for_gpu(target_os: str, vendor: str, renderer: str, seed: Optional[int] = None) -> Dict[str, Any]:
|
||||
"""The WebGL config of a device with this GPU, as Firefox on `target_os` reports it.
|
||||
|
||||
Raises ValueError for a GPU fpgen has never seen Firefox report on that OS:
|
||||
it has no recorded parameters, and another device's would contradict it.
|
||||
"""
|
||||
if (vendor, renderer) not in firefox_gpus(target_os):
|
||||
raise ValueError(
|
||||
f'No recorded WebGL data for vendor {vendor!r} and renderer {renderer!r} '
|
||||
f'from Firefox on {_FPGEN_OS[target_os]}. Possible pairs: '
|
||||
f'{sorted(firefox_gpus(target_os))}'
|
||||
)
|
||||
return _webgl_config(target_os, {'vendor': vendor, 'renderer': renderer}, Random(seed))
|
||||
|
||||
|
||||
def sample_webgl_for_screen(
|
||||
target_os: str,
|
||||
width: Optional[int] = None,
|
||||
height: Optional[int] = None,
|
||||
seed: Optional[int] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Draw a GPU for a synthetic identity, weighted as fpgen records Firefox
|
||||
on `target_os`, and its WebGL config.
|
||||
|
||||
Only GPUs the rest of the identity can stand beside are drawn: never a
|
||||
software rasteriser, a GPU the OS cannot report, the resistFingerprinting
|
||||
mask, or a discrete GPU behind a netbook panel (see gpu_screen_is_plausible).
|
||||
The screen is left alone: it has already been reconciled with the real
|
||||
display and the window (#499).
|
||||
|
||||
Software rasterisers cost fidelity -- real users do run without working
|
||||
drivers -- but "no consumer machine reports llvmpipe" is a live, standard
|
||||
check on a string every fingerprint script reads (sundial, 2026-09-14).
|
||||
"""
|
||||
candidates = tuple(
|
||||
result
|
||||
for result in _trace('gpu', target_os)
|
||||
if not is_software_renderer(result.value['renderer'])
|
||||
and result.value['renderer'] != _RFP_RENDERER
|
||||
and gpu_fits_os(result.value['renderer'], target_os)
|
||||
and gpu_screen_is_plausible(result.value['renderer'], width, height)
|
||||
)
|
||||
if not candidates:
|
||||
raise ValueError(f'No recorded {target_os} GPU fits a {width}x{height} screen')
|
||||
rng = Random(seed)
|
||||
return _webgl_config(target_os, _choose(rng, candidates), rng)
|
||||
@@ -1,3 +0,0 @@
|
||||
from .sample import sample_webgl
|
||||
|
||||
__all__ = ['sample_webgl']
|
||||
@@ -1,166 +0,0 @@
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
import orjson
|
||||
|
||||
from camoufox.pkgman import OS_ARCH_MATRIX
|
||||
|
||||
# Get database path relative to this file
|
||||
DB_PATH = Path(__file__).parent / 'webgl_data.db'
|
||||
|
||||
# Extensions a release Firefox never exposes (draft extensions behind
|
||||
# webgl.enable-draft-extensions, or mobile-only): some database rows carry
|
||||
# them, and a spoofed list that names one is a tell on its own. Measured on
|
||||
# stock Firefox 152.0.4 on real Windows 11 (ANGLE D3D11) 2026-09-16: none of
|
||||
# these four are exposed. WEBGL_provoking_vertex is NOT in this set: stock
|
||||
# Firefox on Apple GPUs and on Windows does expose it.
|
||||
_NEVER_EXPOSED_EXTENSIONS = frozenset(
|
||||
{
|
||||
'WEBGL_multi_draw',
|
||||
'WEBGL_clip_cull_distance',
|
||||
'EXT_texture_norm16',
|
||||
'WEBGL_compressed_texture_etc1',
|
||||
}
|
||||
)
|
||||
|
||||
# OVR_multiview2 is a RELEASE extension whose availability depends on the
|
||||
# graphics backend. On Windows, Firefox renders WebGL through ANGLE's D3D11
|
||||
# backend, which implements multiview on every D3D11 GPU: stock 152.0.4 on
|
||||
# a Windows 11 host exposes it on WebGL2 (MAX_VIEWS_OVR=4, headless and headed), and the
|
||||
# recorded corpus has it on 13 of the 15 Windows rows with WebGL2 (never on
|
||||
# WebGL1) -- filtering it there was a leak. On Linux it depends on
|
||||
# the host GL driver (stock on an NVIDIA box does not expose it), and
|
||||
# ClientWebGLContext::IsSupported answers from the spoofed list without asking
|
||||
# the host, so a Linux identity could advertise an extension the GPU cannot
|
||||
# back; it stays filtered off Windows.
|
||||
_HOST_DEPENDENT_EXTENSIONS = frozenset({'OVR_multiview2'})
|
||||
|
||||
|
||||
def _filtered_extensions(os: str) -> frozenset:
|
||||
if os == 'win':
|
||||
return _NEVER_EXPOSED_EXTENSIONS
|
||||
return _NEVER_EXPOSED_EXTENSIONS | _HOST_DEPENDENT_EXTENSIONS
|
||||
|
||||
|
||||
def _load_webgl_data(data_str: str, os: str) -> Dict[str, str]:
|
||||
data = orjson.loads(data_str)
|
||||
blocked = _filtered_extensions(os)
|
||||
for key in ('webGl:supportedExtensions', 'webGl2:supportedExtensions'):
|
||||
exts = data.get(key)
|
||||
if isinstance(exts, list):
|
||||
data[key] = [e for e in exts if e not in blocked]
|
||||
return data
|
||||
|
||||
|
||||
def sample_webgl(
|
||||
os: str, vendor: Optional[str] = None, renderer: Optional[str] = None, seed: Optional[int] = None
|
||||
) -> Dict[str, str]:
|
||||
"""
|
||||
Sample a random WebGL vendor/renderer combination and its data based on OS probabilities.
|
||||
Optionally use a specific vendor/renderer pair.
|
||||
|
||||
Args:
|
||||
os: Operating system ('win', 'mac', or 'lin')
|
||||
vendor: Optional specific vendor to use
|
||||
renderer: Optional specific renderer to use (requires vendor to be set)
|
||||
|
||||
Returns:
|
||||
Dict containing WebGL data including vendor, renderer and additional parameters
|
||||
|
||||
Raises:
|
||||
ValueError: If invalid OS provided or no data found for OS/vendor/renderer
|
||||
"""
|
||||
# Check that the OS is valid (avoid SQL injection)
|
||||
if os not in OS_ARCH_MATRIX:
|
||||
raise ValueError(f'Invalid OS: {os}. Must be one of: win, mac, lin')
|
||||
|
||||
# Connect to database
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.cursor()
|
||||
|
||||
if vendor and renderer:
|
||||
# Get specific vendor/renderer pair and verify it exists for this OS
|
||||
cursor.execute(
|
||||
f'SELECT vendor, renderer, data, {os} FROM webgl_fingerprints ' # nosec
|
||||
'WHERE vendor = ? AND renderer = ?',
|
||||
(vendor, renderer),
|
||||
)
|
||||
result = cursor.fetchone()
|
||||
|
||||
if not result:
|
||||
raise ValueError(f'No WebGL data found for vendor "{vendor}" and renderer "{renderer}"')
|
||||
|
||||
if result[3] <= 0: # Check OS-specific probability
|
||||
# Get a list of possible (vendor, renderer) pairs for this OS
|
||||
cursor.execute(
|
||||
f'SELECT DISTINCT vendor, renderer FROM webgl_fingerprints WHERE {os} > 0' # nosec
|
||||
)
|
||||
possible_pairs = cursor.fetchall()
|
||||
raise ValueError(
|
||||
f'Vendor "{vendor}" and renderer "{renderer}" combination not valid for {os.title()}.\n'
|
||||
f'Possible pairs: {", ".join(str(pair) for pair in possible_pairs)}'
|
||||
)
|
||||
|
||||
conn.close()
|
||||
return _load_webgl_data(result[2], os)
|
||||
|
||||
# Get all vendor/renderer pairs and their probabilities for this OS
|
||||
cursor.execute(
|
||||
f'SELECT vendor, renderer, data, {os} FROM webgl_fingerprints WHERE {os} > 0' # nosec
|
||||
)
|
||||
results = cursor.fetchall()
|
||||
conn.close()
|
||||
|
||||
if not results:
|
||||
raise ValueError(f'No WebGL data found for OS: {os}')
|
||||
|
||||
# Drop pairs this OS cannot report before sampling. webgl_data.db weights
|
||||
# each pair per OS, and its macOS column carries a Braswell Atom IGP
|
||||
# ("Intel(R) HD Graphics 400") at 7.4% and a desktop PC card ("Radeon R9 200
|
||||
# Series") at 3.7% -- neither shipped in any Mac, so ~11% of macOS
|
||||
# identities were drawing a GPU that would contradict the rest of the
|
||||
# identity the moment a page read the renderer string beside the platform.
|
||||
# Filtering here rather than repairing later keeps reported and sampled
|
||||
# WebGL parameters from the same recorded device.
|
||||
from ..coherence import gpu_fits_os
|
||||
|
||||
coherent = [row for row in results if gpu_fits_os(row[1], os)]
|
||||
if coherent:
|
||||
results = coherent
|
||||
|
||||
# Split into separate arrays
|
||||
_, _, data_strs, probs = map(list, zip(*results))
|
||||
|
||||
# Convert probabilities to numpy array and normalize
|
||||
probs_array = np.array(probs, dtype=np.float64)
|
||||
probs_array = probs_array / probs_array.sum()
|
||||
|
||||
# Sample based on probabilities
|
||||
# Seeded so the same identity always draws the same device (#442/#765).
|
||||
idx = np.random.default_rng(seed).choice(len(probs_array), p=probs_array)
|
||||
|
||||
# Parse the JSON data string
|
||||
return _load_webgl_data(data_strs[idx], os)
|
||||
|
||||
|
||||
def get_possible_pairs() -> Dict[str, List[Tuple[str, str]]]:
|
||||
"""
|
||||
Get all possible (vendor, renderer) pairs for all OS, where the probability is greater than 0.
|
||||
"""
|
||||
# Connect to database
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Get all vendor/renderer pairs for each OS where probability > 0
|
||||
result: Dict[str, List[Tuple[str, str]]] = {}
|
||||
for os_type in OS_ARCH_MATRIX:
|
||||
cursor.execute(
|
||||
'SELECT DISTINCT vendor, renderer FROM webgl_fingerprints '
|
||||
f'WHERE {os_type} > 0 ORDER BY {os_type} DESC', # nosec
|
||||
)
|
||||
result[os_type] = cursor.fetchall()
|
||||
|
||||
conn.close()
|
||||
return result
|
||||
Binary file not shown.
@@ -45,15 +45,14 @@ numpy = "*"
|
||||
ua_parser = "*"
|
||||
typing_extensions = "*"
|
||||
screeninfo = "*"
|
||||
lxml = "*"
|
||||
language-tags = "*"
|
||||
pysocks = "*"
|
||||
inquirer = "*"
|
||||
geoip2 = {version = "*", optional = true}
|
||||
maxminddb = {version = "*", optional = true}
|
||||
PySide6 = {version = "*", optional = true}
|
||||
|
||||
[tool.poetry.extras]
|
||||
geoip = ["geoip2"]
|
||||
geoip = ["maxminddb"]
|
||||
gui = ["PySide6"]
|
||||
|
||||
[tool.poetry.scripts]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"] == []
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"})
|
||||
Reference in New Issue
Block a user