Compare commits

...

82 commits

Author SHA1 Message Date
feder-cr
aa7277a1ef docs(readme): self-host the LinkedIn badge SVG too (removes the last shields.io dependency) 2026-07-19 14:47:47 +02:00
feder-cr
5026a98dec docs(readme): self-host badges as repo SVGs (no more shields.io)
The version/stars/downloads badges routed through shields.io and GitHub's
image proxy, which rate-limits and intermittently rendered them as broken
images. These are now static SVGs committed under docs/badges/ and referenced
by relative path, so they render like any repo asset and cannot break.
2026-07-19 14:32:09 +02:00
feder-cr
cafbbca82f docs(readme): replace flaky dynamic badge with a static Firefox version badge
The github v/release badge (no releases on this repo) and the github stars
badge (shields.io shares a rate-limited GitHub API call, so it intermittently
rendered as a broken image) are replaced by a static Firefox 150.0.1 badge that
never calls an API and cannot break.
2026-07-19 14:15:15 +02:00
feder-cr
e09cc07f69 fix(deps): pin playwright to 1.55.x, bump 0.3.1
Playwright 1.61 adds an isMobile field to the Browser.setDefaultViewport
Juggler command that the FF150 binary does not accept, which breaks the
session (#48). Narrow the range from >=1.40,<1.61 to the CI-tested 1.55.x
(scripts/playwright_pin.txt), so a fresh install always gets a compatible client.
2026-07-13 23:09:26 +02:00
feder-cr
a1b75d93e9 tests: drop font-sampling tests
Follows the invisible_core removal of the dead per-profile font-sampling:
delete the derive_font_prefs/derive_font_whitelist tests, the 'fonts' pin
tests, and the profile font_whitelist assertions. Suite green (444 passed).
2026-07-06 21:36:26 +02:00
feder-cr
47a1044733 style: ASCII-only comment in the font-env test (drop em-dashes) 2026-07-06 04:19:37 +02:00
feder-cr
3f5a26a9e8 test: adapt integration tests to the self-contained font binary
The binary is now bundle-only and self-contained for fonts, so the config
pipeline emits no font prefs. Drop the two font keys from REQUIRED_KEYS and
rewrite IT7 to assert the pipeline emits NO font.* / font.name-list.* prefs
(it previously asserted the removed fontlist propagation). Matches the
invisible_core change that stopped emitting the font block.
2026-07-06 04:11:46 +02:00
feder-cr
a0a0cc2369 scripts: ci_font_gate.py - assert the Windows font persona on every OS
Companion to ci_drive_gate.py, run by release.yml's gate job on each leg's
native runner. Launches the built binary headless, enumerates the visible font
families with the width probe web detectors use, and asserts: the detected set
equals the canonical Windows family set (identical on every OS), no known host
family (macOS Helvetica Neue/Menlo, Linux DejaVu/Ubuntu, ...) is visible, and
the CSS generics resolve to Windows fonts (serif=Times New Roman, mono=Consolas,
system-ui=Segoe UI). This is how the macOS build gets font-validated - there is
no local Mac. No proxy, no secrets.
2026-07-06 03:50:56 +02:00
feder-cr
029b678964 fonts: stop injecting STEALTHFOX_FONTLIST/SYSTEMUI env (binary self-contained)
The patched binary is always bundle-only and needs no external font config, so
the wrapper no longer injects any font env in _build_env (sync + async). No
external font customization channel remains. Test updated.
2026-07-06 03:09:35 +02:00
feder-cr
d3462cca47 test(download): point release-URL mocks at firefox_antidetect_patch
The firefox-14 relocation moved the binary release assets to
firefox_antidetect_patch, but test_download and test_constants still mocked
and asserted the old invisible_playwright release URLs, so they failed
against the current invisible-core. Derive the ensure_binary mock URLs from
RELEASE_URL_TEMPLATE so they track core automatically, and update the two
literal-URL guards to the new repo. Parser and non-api-url tests unchanged.
471 unit tests green.
2026-07-05 17:22:01 +02:00
feder-cr
17bab0cca4 ci: move the binary-release workflows to firefox_antidetect_patch
The release/verify-assets/verify-cloak/republish workflows now live on the
source repo (feder-cr/firefox_antidetect_patch), so the built binaries are
published there — the natural home next to the source they are built from,
and reachable the same way by every consumer of invisible-core.

The wrapper keeps its own test workflows (tests/e2e/webrtc-e2e/
firefox-launch-matrix) and the scripts the source-side release jobs check
out from here (ci_drive_gate.py, gen_release_notes.py, playwright_pin.txt,
tests/test_cloak.py).
2026-07-04 20:00:27 +02:00
feder-cr
3c018f7bbf docs: source repo renamed invisible_firefox -> firefox_antidetect_patch (release notes + CI refs) 2026-07-04 20:00:27 +02:00
feder-cr
310bb215df Allow the invisible-core git dependency to install
Hatchling refuses a direct-reference dependency (invisible-core @ git+...)
unless allow-direct-references is opted in, so a clean pip install from git
failed at metadata generation. Enable it so the wrapper installs.
2026-07-03 21:15:20 +02:00
feder-cr
76e41e2789 Split pure-config into invisible-core; keep back-compat shims
The seed->fingerprint->prefs logic, binary download, and proxy/geo config
now live in a standalone invisible-core package (zero Playwright), so a
profile manager can reuse it without pulling Playwright in. The wrapper
depends on invisible-core and replaces the moved modules with full-alias
shims: existing imports (public API, submodules, private names) and
isinstance checks keep working unchanged. tests/test_backcompat.py locks
that contract (6 guards). Bumps to 0.3.0.
2026-07-03 19:57:49 +02:00
feder-cr
d434ee4059 docs: bigger logo in banner 2026-07-03 05:13:30 +02:00
feder-cr
bb6305b439 docs: center the banner 2026-07-03 05:07:35 +02:00
feder-cr
6a48fb6022 docs: more gap in banner (logo further right) 2026-07-03 05:02:36 +02:00
feder-cr
c9853961d3 docs: title+logo banner lockup (light/dark, responsive image) 2026-07-03 04:56:00 +02:00
feder-cr
b93fc25a3f docs: title/logo lockup via table (vertically centered) 2026-07-03 04:30:31 +02:00
feder-cr
993ec95eb2 docs: badges on top, then title-left + logo-right, centered intro below 2026-07-03 04:22:05 +02:00
feder-cr
8695a6a4e0 docs: restore lockup layout with large title (logo right of title) 2026-07-03 04:21:04 +02:00
feder-cr
1f087fd6d3 docs: centered hero (fixes tablet title wrap, borderless h3 title) 2026-07-03 03:11:33 +02:00
feder-cr
036076a3dd docs: HTML title (test borderless) 2026-07-03 03:06:50 +02:00
feder-cr
17d67e067c docs: center tagline as a subtitle, clear float 2026-07-03 03:02:54 +02:00
feder-cr
faec39c6ab docs: move badges above the title 2026-07-03 01:51:23 +02:00
feder-cr
4458fa585c docs: left-aligned title with logo floated right (lockup style) 2026-07-03 01:36:49 +02:00
feder-cr
163ac7f787 docs: move LinkedIn badge from header to author footer 2026-07-03 01:30:11 +02:00
feder-cr
3d36af5ef6 docs: use HTML badges so they render centered + update logo 2026-07-03 01:20:46 +02:00
feder-cr
a59d008a43 docs: add project logo to README 2026-07-03 01:15:03 +02:00
feder-cr
233f4b088f ci: skip the heavy e2e on docs-only pushes (paths-ignore) 2026-07-02 00:17:03 +02:00
feder-cr
e1de4c3d96 docs: re-add stars badge in the reliable plain format (Skyvern-style) 2026-07-01 16:25:42 +02:00
feder-cr
daf84f78cd docs: drop GitHub stars badge (redundant + flaky; big repos don't use one) 2026-07-01 16:21:08 +02:00
feder-cr
bf302701c6 docs: fix stars badge (drop flaky social/.svg format) 2026-07-01 16:17:11 +02:00
feder-cr
dd59c41bb5 docs: title + full subtitle 2026-07-01 04:01:46 +02:00
feder-cr
24ad6e4233 docs: shorten title 2026-06-30 20:27:22 +02:00
feder-cr
7914f3d006 docs: single-line title 2026-06-30 20:25:40 +02:00
feder-cr
050ec1aa61 docs: subtitle uses 'passes' 2026-06-30 20:24:42 +02:00
feder-cr
07a812c1f2 docs: short title + concise subtitle 2026-06-30 20:24:23 +02:00
feder-cr
1c942713aa docs: trim title 2026-06-30 19:45:35 +02:00
feder-cr
acc5c27d5b docs: make the title a one-line description 2026-06-30 19:45:08 +02:00
feder-cr
4cbe475473 docs: short title + concise description subtitle 2026-06-30 19:44:02 +02:00
feder-cr
1b22e1bb4a docs: put badges above the title 2026-06-30 19:36:59 +02:00
feder-cr
9ad2b1eee6 docs: drop Firefox version badge, merge LinkedIn into the badge row 2026-06-30 19:35:54 +02:00
feder-cr
dbffc52570 docs: shorten hero to a single line 2026-06-30 19:33:38 +02:00
feder-cr
81849a32d2 docs: refine hero tagline 2026-06-30 19:32:51 +02:00
feder-cr
e4c31427e3 docs: tighten hero tagline 2026-06-30 19:29:34 +02:00
feder-cr
f26df624c4 docs: trim hero tagline 2026-06-30 19:28:36 +02:00
feder-cr
4f882b550f docs: hero opens with the project name 2026-06-30 19:27:33 +02:00
feder-cr
74ad6d0fce docs: hero tagline reflects both pillars (real browser + human behavior) 2026-06-30 19:11:45 +02:00
feder-cr
cfa33e8756 docs: rewrite How it works around the two-question model (real browser + human behavior) 2026-06-30 19:07:26 +02:00
feder-cr
d85e407bbc docs: remove the How it compares section (clutter) 2026-06-30 04:00:07 +02:00
feder-cr
475cb3f4fc docs: swap order of How it compares and the proxy section 2026-06-30 03:58:53 +02:00
feder-cr
eeddb8fcde docs: promote the proxy section to a top-level heading 2026-06-30 03:56:03 +02:00
feder-cr
7e06d60f04 docs: truthful proxy framing (public/known IPs, not datacenter) 2026-06-30 01:47:09 +02:00
feder-cr
92b374a497 docs: bulleted How it works, bridge to solution, drop the suites claim 2026-06-30 01:38:52 +02:00
feder-cr
fa869c52b8 docs: merge the C++ paragraph into the intro (drop the subheading) 2026-06-30 01:30:10 +02:00
feder-cr
7a42984158 fix(deps): cap playwright <1.61 and pin it in the e2e clean venv
Playwright 1.61.0 (2026-06-29) ships a Browser.setDefaultViewport Juggler
call the published firefox-13 binary does not implement, so an unpinned
pip install (the wrapper declared playwright>=1.40) breaks new_context on
fresh installs. Cap the dependency to <1.61 and force the blessed pin in
the e2e clean venv so CI tests the version users actually run.
2026-06-30 09:00:00 +00:00
feder-cr
95b1e47e0c docs: align Proxies usage note to 90/10 for consistency 2026-06-29 18:07:13 +02:00
feder-cr
62007c21d8 docs: rewrite How it works (browser undetectable, proxy is the remaining variable) 2026-06-29 18:06:46 +02:00
feder-cr
6cca6b3a69 docs: sharpen the proxy framing (99% already flagged as proxies) 2026-06-29 17:48:35 +02:00
feder-cr
3046ca17d3 docs: phrase sx.org as a recommendation, not a partnership 2026-06-29 17:46:21 +02:00
feder-cr
2aa14d9b5b docs: drop the competitor-naming intro line in How it compares 2026-06-29 17:44:21 +02:00
feder-cr
0b5edf4e35 docs: note proxy partner in the Proxies usage section 2026-06-29 17:37:50 +02:00
feder-cr
0e6b0dddfe docs: add proxy partner note to How it works 2026-06-29 17:32:29 +02:00
feder-cr
2184f6f3c2 docs: restore C++ paragraph content, remove em-dashes 2026-06-25 04:32:32 +02:00
feder-cr
d35ac458a7 docs: remove LLM phrasing throughout README 2026-06-25 04:31:47 +02:00
feder-cr
567d921d5f docs: merge and trim C++ patch paragraphs 2026-06-25 04:30:01 +02:00
feder-cr
0b54484b63 docs: remove auth implementation detail from README 2026-06-25 04:28:44 +02:00
feder-cr
be940b0084 docs: remove preferences line from README 2026-06-25 04:27:32 +02:00
feder-cr
29df4e873c docs: shorten Chromium point in README 2026-06-25 04:26:40 +02:00
feder-cr
9b9e63972c docs: add educational disclaimer to README 2026-06-25 04:25:03 +02:00
feder-cr
168179eb03 docs: remove LLM phrasing from README 2026-06-25 04:23:59 +02:00
feder-cr
02b5f397ad docs: rewrite SECURITY.md, add disclaimer, remove LLM language 2026-06-25 04:22:39 +02:00
feder-cr
82eb22634e chore: remove LLM-style phrasing from issue templates 2026-06-25 04:16:47 +02:00
feder-cr
929c30219f chore: remove security vulnerability contact link from issue config 2026-06-25 04:15:08 +02:00
feder-cr
01d56601ae chore: remove feature request issue template 2026-06-25 04:14:05 +02:00
feder-cr
b00bd74a9e chore: remove firefox source bug link from issue config 2026-06-25 04:08:24 +02:00
feder-cr
64641db82e chore: update stealth detection issue template
- add Firefox revision field
- replace verbose verdict field with reproduction script
- rename snippet label to Reproduction script
2026-06-25 04:07:07 +02:00
feder-cr
74b674516b fix(fonts): core pool uses the real Win11 family "franklin gothic medium"
The pool had the bare "franklin gothic", which isn't a real Win11 family and
wouldn't match the bundled franklingothic_x.ttf, so the always-present standard
fonts test failed and the family could drop off the whitelist. Use the real
name (the bundle reconciliation already maps it).
2026-06-24 09:37:11 +02:00
feder-cr
2bc52f4a61 Pin firefox-13: geo-aware locale/Intl + Windows font bundle + audio gate
firefox-13 ships the bundled Windows fonts, makes navigator.languages /
Accept-Language / the Intl locale all follow the proxy country, and gates the
audio fingerprint noise off. The prefs.py change that emits the new font/locale
prefs needs this binary, so the bump and that change land together.
2026-06-24 09:33:45 +02:00
feder-cr
3c0efa2d4f Geo-aware locale, audio noise off, font prefs for the bundled-font binary
locale defaults to "auto" and resolves from the proxy egress country the same
way timezone does - it reuses the egress IP, maps the country to a BCP-47 locale
with the offline mmdb, and falls back to en-US. prefs emit juggler.locale.override
(the full Accept-Language list) so the binary keeps navigator.languages and the
Intl default locale in sync.

The audio fingerprint noise is off in the baseline. Font prefs match the new
binary: the sampled whitelist drives font.system.whitelist, system-ui is Segoe
UI, the bundled fonts are activated, and the CSS generics are pinned to Windows
defaults so they resolve on a non-Windows host too.
2026-06-23 17:51:58 +02:00
feder-cr
8f4b20a19d Pin firefox-12: cross-OS render parity + always-present standard fonts
- BINARY_VERSION -> firefox-12 (self-calibrating font widths, per-family canvas
  distinctness, render-noise that preserves solid reference renders).
- font_pool: the standard Windows fonts (Calibri, Franklin Gothic, Gadugi,
  Javanese Text, Myanmar Text) move from the per-profile optional set to core,
  so they are always present and the detected font set matches a real Windows
  install on every host. Defensive dedup in derive_font_prefs.
- GPU persona applied on every platform (Linux/macOS present a coherent Windows
  GPU + WebGL params); pool re-rooted on a real-device GPU mix; render seeds
  recalibrated.
- prefs: emit absolute per-family font widths that the binary self-calibrates.
- geoip: always pull the latest mmdb via the releases/latest permalink, checked
  each launch, offline-safe (no pinned tag that can 404).
- tests: per-font canvas distinctness, solid-readback purity under render-noise,
  always-present standard-font invariant, no duplicate families.
2026-06-19 03:19:30 +02:00
73 changed files with 888 additions and 11969 deletions

View file

@ -72,7 +72,7 @@ body:
id: logs
attributes:
label: Extra logs
description: Output of `DEBUG=pw:browser* python yourscript.py 2>&1`. Optional but speeds things up.
description: Output of `DEBUG=pw:browser* python yourscript.py 2>&1`. Optional.
render: text
validations:
required: false

View file

@ -88,7 +88,7 @@ body:
id: snippet
attributes:
label: Runnable reproduction
description: A complete snippet we can copy, paste, run. Stub creds with placeholders, keep everything else literal.
description: Runnable. Stub creds, keep everything else literal.
render: python
value: |
from invisible_playwright import InvisiblePlaywright

View file

@ -14,10 +14,20 @@ body:
id: version
attributes:
label: Version
description: Output of `python -m invisible_playwright version`.
placeholder: 0.1.7 (binary firefox-7)
validations:
required: true
- type: input
id: firefox_rev
attributes:
label: Firefox revision
description: The `firefox-N` tag the binary was built from (shown in the version output above).
placeholder: firefox-7
validations:
required: true
- type: dropdown
id: os
attributes:
@ -63,34 +73,19 @@ body:
validations:
required: true
- type: textarea
id: scores
attributes:
label: Detector verdict
description: Paste the relevant flags / scores verbatim. For Fingerprint Pro paste `bot`, `vpn`, `virtual_machine`, `tampering*`, `vm_ml_score`, `suspect_score`. For CreepJS the headless / lies / trust scores. For reCAPTCHA v3 the score number.
render: text
placeholder: |
bot: bad
vpn: true
virtual_machine: true
vm_ml_score: 0.74
suspect_score: 22
validations:
required: true
- type: textarea
id: screenshot
attributes:
label: Screenshot of the detector result
description: Drag-drop a screenshot of the detector page so we see what you see.
description: Screenshot of the detector result.
validations:
required: true
- type: textarea
id: snippet
attributes:
label: How you launched
description: The InvisiblePlaywright launch + navigation that produced the result above. Redact creds.
label: Reproduction script
description: Runnable. Redact creds.
render: python
value: |
from invisible_playwright import InvisiblePlaywright
@ -99,6 +94,7 @@ body:
ctx = browser.new_context()
page = ctx.new_page()
page.goto("https://demo.fingerprint.com/playground")
# add any extra steps needed to reach the flagged result
validations:
required: true
@ -106,7 +102,7 @@ body:
id: expected
attributes:
label: What you expected
description: Most detectors will never give a perfect score for any browser. Tell us what threshold you'd accept (e.g. bot=not_detected, vm_ml_score < 0.3).
description: "e.g. bot=not_detected, vm_ml_score < 0.3"
validations:
required: true
@ -114,7 +110,7 @@ body:
id: full_report
attributes:
label: Full detector response
description: For Fingerprint Pro paste the JSON from /api/event/v4/ if you have it. For CreepJS paste the full Smart Signals block. Optional but speeds things up a lot.
description: "FP Pro: JSON from /api/event/v4/. CreepJS: Smart Signals block. Optional."
render: json
validations:
required: false

View file

@ -1,11 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Security vulnerability
url: https://github.com/feder-cr/invisible_playwright/security/advisories/new
about: Report a security issue privately. Do NOT open a public issue.
- name: Bug in the patched Firefox source (C++, IDL, Juggler JS)
url: https://github.com/feder-cr/invisible_firefox/issues
about: Source-level patches in the Firefox fork go in the invisible_firefox repo. Detection results (FpJS, CreepJS, etc.) use the stealth detection template here.
- name: Question or general discussion
url: https://github.com/feder-cr/invisible_playwright/discussions
about: Usage questions, ideas, chat. Bugs and features still go in issues.

View file

@ -1,47 +0,0 @@
name: Feature request
description: Suggest a new feature or improvement
title: "[feature] "
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Thanks for the suggestion! Please check that:
- Your idea is **in scope** for this repo (the Python wrapper, sampler, CLI, packaging).
- Changes to the patched Firefox C++ source belong at [feder-cr/firefox-stealth](https://github.com/feder-cr/firefox-stealth) instead.
- You have searched [existing issues](https://github.com/feder-cr/invisible_playwright/issues?q=is%3Aissue) for similar requests.
- type: textarea
id: problem
attributes:
label: Problem
description: What problem does this solve? What can't you currently do, or what is awkward today?
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposed solution
description: How would the feature work? API sketches, CLI examples, or pseudocode welcome.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: Other approaches you thought about and why they fall short.
validations:
required: false
- type: textarea
id: context
attributes:
label: Additional context
description: Links to related issues, prior art in other libraries, screenshots, etc.
validations:
required: false
- type: checkboxes
id: contribute
attributes:
label: Are you willing to contribute?
options:
- label: I'd be willing to open a PR for this if accepted.
required: false

View file

@ -19,6 +19,10 @@ name: e2e
on:
push:
branches: [main]
paths-ignore:
- '**.md'
- 'docs/**'
- 'LICENSE'
pull_request:
branches: [main]
workflow_dispatch:

View file

@ -1,479 +0,0 @@
# ─────────────────────────────────────────────────────────────────────────────
# release.yml — build all 5 patched-Firefox targets at $0 and publish them as
# DRAFT GitHub Release assets, named per the wrapper contract (constants.ARCHIVE_NAME).
# DRAFT on purpose: a human runs the realness gate and only THEN un-drafts + bumps
# BINARY_VERSION. Nothing auto-ships (issue #14 lesson).
#
# PACKAGING (issue #14: dangling symlinks broke 265 downloads — never again):
# Linux → cp -aL (dereference ALL symlinks into real files) + rm dev tools +
# strip + sanitize + tar at ROOT, then validate_release.py as a HARD
# in-pipeline gate (the exact battle-tested script from the source repo).
# Win → mach package; zip the CONTENTS of dist/firefox (clean tree, NOT
# dist/bin) so firefox.exe sits at the zip ROOT.
# macOS → mach package; ad-hoc codesign the .app; PRESERVE its internal relative
# symlinks (a .app legitimately has them — cp -aL would break it); verify
# every symlink is relative+internal; tar the bundle. --version self-gate.
#
# DRIVE GATE (the firefox-8 catcher): after build, every binary is DRIVEN by
# Playwright on its native runner (launch via juggler + real page + JS roundtrip,
# headless, no screenshot → GPU-free, zero proxy). A juggler-less binary renders
# a screenshot fine but is undrivable — only an actual drive catches that. The
# proxy realness gate (fppro/webrtc) stays LOCAL — it needs secrets.
#
# Trigger: push a tag `firefox-N`, or run manually. Hybrid runners, all free.
# ─────────────────────────────────────────────────────────────────────────────
name: release
on:
push:
tags: ['firefox-*']
workflow_dispatch:
inputs:
source_ref:
description: 'invisible_firefox ref to build'
default: 'stealth/150'
release_tag:
description: 'release tag to publish the draft under (e.g. firefox-9)'
required: true
env:
SOURCE_REPO: feder-cr/invisible_firefox
SOURCE_REF: ${{ github.event.inputs.source_ref || 'stealth/150' }}
jobs:
build:
name: build-${{ matrix.leg }}
runs-on: ${{ matrix.runner }}
timeout-minutes: 350
strategy:
fail-fast: false
matrix:
include:
- leg: linux-x86_64
runner: ubuntu-24.04
family: linux
target: ''
rust_target: x86_64-unknown-linux-gnu
win_disables: 'no'
extra_pkgs: ''
asset: firefox-150.0.1-stealth-linux-x86_64.tar.gz
- leg: linux-arm64
runner: ubuntu-24.04-arm
family: linux
target: ''
rust_target: aarch64-unknown-linux-gnu
win_disables: 'no'
extra_pkgs: ''
asset: firefox-150.0.1-stealth-linux-arm64.tar.gz
- leg: win-x86_64
runner: ubuntu-24.04
family: win
target: x86_64-pc-windows-msvc
rust_target: x86_64-pc-windows-msvc
win_disables: 'yes'
extra_pkgs: 'msitools p7zip-full zip'
asset: firefox-150.0.1-stealth-win-x86_64.zip
- leg: macos-arm64
runner: macos-15
family: mac
target: aarch64-apple-darwin
rust_target: aarch64-apple-darwin
win_disables: 'no'
extra_pkgs: ''
asset: firefox-150.0.1-stealth-macos-arm64.tar.gz
- leg: macos-x86_64
runner: macos-15-intel
family: mac
target: x86_64-apple-darwin
rust_target: x86_64-apple-darwin
win_disables: 'no'
extra_pkgs: ''
asset: firefox-150.0.1-stealth-macos-x86_64.tar.gz
steps:
- name: Free disk + 16G swap (Linux runners)
if: matrix.family != 'mac'
run: |
sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android \
/usr/local/share/boost "${AGENT_TOOLSDIRECTORY:-/opt/hostedtoolcache}" 2>/dev/null || true
sudo fallocate -l 16G /swapfile && sudo chmod 600 /swapfile && sudo mkswap /swapfile && sudo swapon /swapfile || true
- name: Checkout patched Firefox source
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
repository: ${{ env.SOURCE_REPO }}
ref: ${{ env.SOURCE_REF }}
fetch-depth: 1
# Record which invisible_firefox commit this build came from. The publish
# job turns the range previous-release..this commit into the release notes
# (scripts/gen_release_notes.py), and re-publishes it as a source-commit.txt
# asset so the NEXT release knows where to start the changelog. One leg is
# enough — all legs check out the same SOURCE_REF.
- name: Record source commit (for auto release notes)
if: matrix.leg == 'linux-x86_64'
shell: bash
run: git rev-parse HEAD > source-commit.txt && cat source-commit.txt
- name: Upload source-commit artifact
if: matrix.leg == 'linux-x86_64'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: source-commit
path: source-commit.txt
if-no-files-found: error
retention-days: 7
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with: { python-version: '3.11' }
- name: Install Linux build tools
if: matrix.family != 'mac'
run: |
sudo apt-get update
sudo apt-get install -y util-linux binutils ${{ matrix.extra_pkgs }}
- name: Select Xcode 26.2 + export SDK path (macOS)
if: matrix.family == 'mac'
run: |
sudo xcode-select -s /Applications/Xcode_26.2.app
SDKP="$(xcrun --show-sdk-path)"
echo "SDK_PATH=$SDKP" >> "$GITHUB_ENV"
echo "macOS SDK $(xcrun --sdk macosx --show-sdk-version) at $SDKP"
- name: Add Rust target
run: rustup target add ${{ matrix.rust_target }} || true
- name: Extend the repo .mozconfig (NO mold; +target/SDK as needed)
run: |
test -f .mozconfig || { echo "ERROR: no .mozconfig in source"; exit 1; }
rm -f mozconfig
{
echo ""
echo "# --- release CI levers for ${{ matrix.leg }} (mold intentionally OFF — it segfaults libxul) ---"
echo "ac_add_options --disable-debug-symbols"
} >> .mozconfig
if [ -n "${{ matrix.target }}" ]; then echo "ac_add_options --target=${{ matrix.target }}" >> .mozconfig; fi
if [ "${{ matrix.family }}" = "mac" ]; then echo "ac_add_options --with-macos-sdk=$SDK_PATH" >> .mozconfig; fi
if [ "${{ matrix.win_disables }}" = "yes" ]; then
{ echo "ac_add_options --disable-default-browser-agent";
echo "ac_add_options --disable-maintenance-service";
echo "ac_add_options --disable-update-agent"; } >> .mozconfig
fi
if [ "${{ matrix.family }}" = "mac" ]; then NCPU=$(sysctl -n hw.ncpu); else NCPU=4; fi
{ echo "mk_add_options MOZ_PARALLEL_BUILD=$NCPU";
echo "mk_add_options MOZ_OBJDIR=@TOPSRCDIR@/obj-rel"; } >> .mozconfig
echo "----- final .mozconfig -----"; cat .mozconfig
- name: Build
run: ./mach build
# ── LINUX: dereference symlinks (issue #14) + strip + sanitize + tar@root + GATE
- name: Package + validate (Linux)
if: matrix.family == 'linux'
run: |
set -e
DIST=obj-rel/dist/bin
STAGING=staging
rm -rf "$STAGING"; mkdir -p "$STAGING" out
cp -aL "$DIST/." "$STAGING/" # -L: dereference ALL symlinks into real files
N=$(find "$STAGING" -type l | wc -l)
[ "$N" -eq 0 ] || { echo "ERROR: $N symlinks remain after cp -aL"; exit 1; }
for t in xpcshell certutil pk12util rapl; do rm -f "$STAGING/$t"; done
# JUGGLER GATE: the binary is undrivable by Playwright without it (see 70-known-bugs)
{ [ -e "$STAGING/chrome/juggler.manifest" ] && [ -d "$STAGING/chrome/juggler" ]; } \
|| { echo "ERROR: juggler missing from package (chrome/juggler) — Playwright can't drive it"; exit 1; }
echo "juggler GATE OK (loose chrome/juggler present)"
find "$STAGING" -type f \
\( -name '*.so' -o -name firefox -o -name firefox-bin -o -name plugin-container \
-o -name pingsender -o -name glxtest -o -name vaapitest -o -name updater \) \
-exec strip --strip-debug {} + 2>/dev/null || true
STAGING="$STAGING" python3 scripts/linux_sanitize.py || true # no-op in CI (no /home/feder), defensive
tar --owner=0 --group=0 --numeric-owner --mtime="2026-01-01 00:00:00 UTC" \
-czf "out/${{ matrix.asset }}" -C "$STAGING" . # firefox at ROOT
echo "=== HARD GATE: scripts/validate_release.py (the issue-#14 protector) ==="
python3 scripts/validate_release.py --linux "out/${{ matrix.asset }}" --linux-only
ls -la out/
# ── WINDOWS (cross): zip the CLEAN dist/firefox tree, firefox.exe at root
- name: Package (Windows cross)
if: matrix.family == 'win'
run: |
set -e
# Do NOT swallow a mach failure: `./mach package || echo` lets set -e pass
# and would fall through to a stale tree. A release MUST come from the clean
# dist/firefox; dist/bin is the dev tree (cruft + loose juggler that masked
# the firefox-7/8 packaging bugs), never acceptable for a release.
./mach package
[ -f obj-rel/dist/firefox/firefox.exe ] \
|| { echo "ERROR: mach package did not produce a clean dist/firefox tree"; exit 1; }
WIN_APP=obj-rel/dist/firefox
echo "packaging from: $WIN_APP"
# JUGGLER GATE: omni.ja must carry juggler (else Playwright can't drive it)
[ -f "$WIN_APP/omni.ja" ] || { echo "ERROR: no omni.ja in $WIN_APP"; exit 1; }
python3 -c "import zipfile,sys; sys.exit(0 if any('juggler' in n.lower() for n in zipfile.ZipFile('$WIN_APP/omni.ja').namelist()) else 1)" \
|| { echo "ERROR: juggler missing from $WIN_APP/omni.ja — Playwright can't drive it"; exit 1; }
echo "juggler GATE OK (win)"
mkdir -p out
( cd "$WIN_APP" && zip -qr "$GITHUB_WORKSPACE/out/${{ matrix.asset }}" . ) # firefox.exe at zip ROOT
ls -la out/
# ── macOS: package .app, ad-hoc sign, verify relative-internal symlinks, --version gate, tar
- name: Package + validate (macOS)
if: matrix.family == 'mac'
run: |
set -e
./mach package
APP="$(find obj-rel/dist -maxdepth 2 -name '*.app' -type d | head -1)"
[ -n "$APP" ] || { echo "ERROR: no .app produced"; exit 1; }
echo "built app: $APP"
# JUGGLER GATE: the .app's omni.ja must carry juggler (else Playwright can't drive it)
python3 -c "import zipfile,sys,glob; jas=glob.glob('$APP/Contents/Resources/omni.ja')+glob.glob('$APP/Contents/Resources/browser/omni.ja'); sys.exit(0 if jas and any(any('juggler' in n.lower() for n in zipfile.ZipFile(j).namelist()) for j in jas) else 1)" \
|| { echo "ERROR: juggler missing from .app omni.ja — Playwright can't drive it"; exit 1; }
echo "juggler GATE OK (mac)"
codesign --force --deep --sign - --timestamp=none "$APP"
codesign --verify --deep --strict --verbose=2 "$APP"
echo "=== --version GATE ==="
"$APP/Contents/MacOS/firefox" --version
echo "=== critical files present ==="
for need in "Contents/MacOS/firefox" "Contents/Info.plist"; do
[ -e "$APP/$need" ] || { echo "ERROR: missing $need"; exit 1; }
done
echo "=== Info.plist well-formed + required keys (a malformed plist → Finder 'damaged') ==="
plutil -lint "$APP/Contents/Info.plist"
for key in CFBundleExecutable CFBundleIdentifier CFBundleShortVersionString; do
plutil -extract "$key" raw -o - "$APP/Contents/Info.plist" >/dev/null \
|| { echo "ERROR: Info.plist missing $key"; exit 1; }
done
EXEC="$(plutil -extract CFBundleExecutable raw -o - "$APP/Contents/Info.plist")"
[ -e "$APP/Contents/MacOS/$EXEC" ] \
|| { echo "ERROR: CFBundleExecutable '$EXEC' has no matching binary in Contents/MacOS"; exit 1; }
echo "=== verify NO absolute symlinks in the .app (relative-internal ones are fine) ==="
BAD="$(find "$APP" -type l -print0 | xargs -0 -I{} sh -c 't=$(readlink "{}"); case "$t" in /*) echo "{} -> $t";; esac')"
[ -z "$BAD" ] || { echo "ERROR: absolute symlinks in .app (break on user machines):"; echo "$BAD" | head -5; exit 1; }
echo "mac .app OK: critical files present, no absolute symlinks"
STABLE="$(dirname "$APP")/Firefox.app"
[ "$APP" = "$STABLE" ] || mv "$APP" "$STABLE"
mkdir -p out
tar -czf "out/${{ matrix.asset }}" -C "$(dirname "$STABLE")" Firefox.app # preserves internal symlinks
ls -la out/
- name: Upload build artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: asset-${{ matrix.leg }}
path: out/${{ matrix.asset }}
if-no-files-found: error
retention-days: 7
# DRIVE GATE — the firefox-8 catcher. A raw `firefox --screenshot` proves
# nothing about automation: a juggler-less binary renders fine and ships
# broken (firefox-8 did exactly that). So we DRIVE every binary the way users
# will: Playwright launches it over the juggler pipe, loads a real page, and
# round-trips JS. A binary missing/broken juggler throws TargetClosedError
# here and the release never publishes. Headless, NO screenshot → GPU-free,
# so it can't false-fail on the GPU-less hosted runners. Zero proxy / zero
# secrets → safe in public CI (the proxy realness gate stays local, by design).
# Each leg runs on its NATIVE runner so we test the real artifact, not a cross
# surrogate. Playwright is pinned to a version validated against this build's
# juggler; bump it in lockstep when the juggler is re-synced from upstream.
gate:
name: gate-${{ matrix.leg }}
needs: build
runs-on: ${{ matrix.runner }}
timeout-minutes: 25
strategy:
fail-fast: false
matrix:
include:
# `extra: --full` adds the mouse/keyboard/canvas/navsurface interaction
# checks. Only on linux-x86_64 (historically the most reliable hosted
# runner): the interaction code is platform-identical JS (omni.ja), so
# one reliable full run catches a firefox-2-class regression for all
# platforms. The other legs run SMOKE (launch+http+UA+webdriver) — the
# firefox-8/juggler catcher — which is robust even on the flaky
# windows-latest runner. See scripts/ci_drive_gate.py.
- leg: linux-x86_64
runner: ubuntu-24.04
kind: linux
asset: firefox-150.0.1-stealth-linux-x86_64.tar.gz
extra: '--full'
- leg: linux-arm64
runner: ubuntu-24.04-arm
kind: linux
asset: firefox-150.0.1-stealth-linux-arm64.tar.gz
extra: ''
- leg: win-x86_64
runner: windows-latest
kind: win
asset: firefox-150.0.1-stealth-win-x86_64.zip
extra: ''
- leg: macos-arm64
runner: macos-15
kind: mac
asset: firefox-150.0.1-stealth-macos-arm64.tar.gz
extra: ''
- leg: macos-x86_64
runner: macos-15-intel
kind: mac
asset: firefox-150.0.1-stealth-macos-x86_64.tar.gz
extra: ''
steps:
- name: Checkout wrapper (for scripts/ci_drive_gate.py)
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with: { fetch-depth: 1 }
- name: Download asset
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: asset-${{ matrix.leg }}
path: art
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with: { python-version: '3.11' }
- name: Install Playwright driver (no bundled browser — we override executable_path)
# Pin from a SINGLE source (scripts/playwright_pin.txt) so release.yml and
# verify-assets.yml can't drift to different versions. The drive gate then
# ENFORCES playwright↔juggler compatibility: an incompatible pin fails the
# launch/drive (TargetClosedError / protocol error) and nothing publishes.
# Bump the pin file in lockstep when the juggler is re-synced from upstream.
shell: bash
run: python -m pip install --quiet "playwright==$(cat scripts/playwright_pin.txt)"
- name: Linux system deps for headless firefox
if: matrix.kind == 'linux'
run: sudo "$(which python)" -m playwright install-deps firefox
- name: Extract + locate firefox binary
shell: bash
run: |
set -e
mkdir -p ff
A="art/${{ matrix.asset }}"
case "${{ matrix.kind }}" in
win) python -c "import zipfile; zipfile.ZipFile('$A').extractall('ff')"; EXE="ff/firefox.exe";;
linux) tar xzf "$A" -C ff; EXE="ff/firefox";;
mac) tar xzf "$A" -C ff; EXE="ff/Firefox.app/Contents/MacOS/firefox";;
esac
[ -e "$EXE" ] || { echo "ERROR: firefox binary not found at $EXE"; exit 1; }
chmod +x "$EXE" 2>/dev/null || true
echo "FF_EXE=$EXE" >> "$GITHUB_ENV"
echo "located: $EXE"
- name: DRIVE GATE — Playwright launch via juggler + real page (+ interaction on --full)
shell: bash
run: python scripts/ci_drive_gate.py "$FF_EXE" ${{ matrix.extra }}
# CLOAK + WEBGL-MASKING GUARDS — run the wrapper's e2e cloak/gamma checks
# against THIS leg's freshly-built artifact, on its native runner. The
# wrapper's headless=True is headed+hidden (cloak on Win/macOS, its own
# Xvfb on Linux). Linux (Xvfb + llvmpipe) and Windows (WARP) give a
# software WebGL context on the GPU-less hosts, so the WebGL-dependent
# assertions run there. macOS GitHub runners expose NO WebGL in the CI
# session at all (even vanilla Firefox; macOS has no software-GL fallback),
# so on the mac legs the WebGL checks self-skip and the cloak is validated
# via its non-blank screenshot + CGWindowAlpha == 0. test_cloak asserts the
# window is hidden (Windows DWMWA_CLOAKED / macOS CGWindowAlpha) AND still
# renders — the macOS leg is the only place the cocoa cloak patch gets RUN.
# The webgl guard catches a regression of the gamma readPixels noise back to
# the pixelscan-maskable ±1 spike form (covered on Linux + Windows).
- name: Install pyobjc Quartz (macOS — to read the cloak window alpha)
if: matrix.kind == 'mac'
run: python -m pip install --quiet pyobjc-framework-Quartz
- name: Cloak + WebGL-masking guards (headed)
shell: bash
run: |
python -m pip install --quiet ".[dev]"
INVPW_BINARY_PATH="$FF_EXE" python -m pytest \
tests/test_cloak.py \
"tests/test_fingerprint_surface.py::test_webgl_readpixels_no_masking_signature" \
-m e2e -o addopts='' -q
publish:
name: publish-draft-release
needs: [build, gate]
runs-on: ubuntu-24.04
permissions:
contents: write
steps:
- name: Checkout wrapper (for scripts/gen_release_notes.py)
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with: { fetch-depth: 1 }
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with: { python-version: '3.11' }
- name: Download all build assets
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with: { pattern: asset-*, path: dl, merge-multiple: true }
- name: Download source-commit metadata
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with: { name: source-commit, path: src-meta }
- name: Assert all 5 target archives present (no silent partial release)
run: |
cd dl
EXPECTED="
firefox-150.0.1-stealth-linux-x86_64.tar.gz
firefox-150.0.1-stealth-linux-arm64.tar.gz
firefox-150.0.1-stealth-win-x86_64.zip
firefox-150.0.1-stealth-macos-arm64.tar.gz
firefox-150.0.1-stealth-macos-x86_64.tar.gz
"
for a in $EXPECTED; do
[ -s "$a" ] || { echo "ERROR: missing/empty release asset: $a (a build leg silently dropped out?)"; exit 1; }
done
echo "all 5 target archives present"
- name: Generate checksums.txt
run: |
cd dl; ls -la
# explicit glob — never include checksums.txt itself (the `*`-includes-itself trap)
sha256sum firefox-150.0.1-stealth-* > checksums.txt
echo "----- checksums.txt -----"; cat checksums.txt
- name: Resolve release tag
id: tag
run: |
TAG="${{ github.event.inputs.release_tag }}"
[ -z "$TAG" ] && TAG="${GITHUB_REF_NAME}"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
# bare revision number for the release title: firefox-10 -> 10
N="${TAG#firefox-}"
echo "num=$N" >> "$GITHUB_OUTPUT"
# previous release tag, for the changelog range (firefox-10 -> firefox-9)
case "$N" in (*[!0-9]*|'') echo "prevtag=" >> "$GITHUB_OUTPUT";;
(*) echo "prevtag=firefox-$((N-1))" >> "$GITHUB_OUTPUT";; esac
echo "publishing DRAFT release for tag: $TAG"
- name: Build release notes from the source commits
id: notes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -e
CUR="$(cat src-meta/source-commit.txt 2>/dev/null | tr -d '[:space:]')"
echo "this build's source commit: ${CUR:-<none>}"
# previous release's recorded source commit — gives the changelog range.
# Missing (first automated notes / firefox-0) -> notes omit the changelog.
PREV=""
PREVTAG="${{ steps.tag.outputs.prevtag }}"
if [ -n "$PREVTAG" ] && gh release download "$PREVTAG" -R "${{ github.repository }}" \
--pattern source-commit.txt --dir prev 2>/dev/null; then
PREV="$(cat prev/source-commit.txt | tr -d '[:space:]')"
echo "previous ($PREVTAG) source commit: $PREV"
else
echo "no previous source-commit.txt — changelog section omitted this time"
fi
python scripts/gen_release_notes.py --tag "${{ steps.tag.outputs.tag }}" \
--current "$CUR" --prev-sha "$PREV" --source-repo "${{ env.SOURCE_REPO }}" > body.md
echo "----- generated body.md -----"; cat body.md
# publish THIS build's source commit so the next release can diff from it
cp src-meta/source-commit.txt dl/source-commit.txt
- name: Create DRAFT release with all assets
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
with:
tag_name: ${{ steps.tag.outputs.tag }}
name: invisible_firefox (150.0.1) rev ${{ steps.tag.outputs.num }}
draft: true
prerelease: false
fail_on_unmatched_files: true
files: |
dl/*.tar.gz
dl/*.zip
dl/checksums.txt
dl/source-commit.txt
body_path: body.md
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View file

@ -1,214 +0,0 @@
# ─────────────────────────────────────────────────────────────────────────────
# republish.yml — re-gate + publish an EXISTING build's artifacts WITHOUT rebuilding.
#
# When a release run's 5 builds all SUCCEEDED but the publish was blocked by a
# GATE/TEST bug (not a binary defect), rebuilding all five targets (hours) just to
# re-run a few-minute test is wasteful. This workflow downloads the binary artifacts
# from a prior `release` run, re-runs the (now-fixed) drive gate + cloak/WebGL guards
# against those exact byte-identical binaries on their native runners, and — only if
# green — publishes the DRAFT release. Same publish logic as release.yml (draft, notes,
# checksums, source-commit) so the human still runs the realness gate before un-drafting.
#
# Inputs:
# build_run_id : the `release` run whose `asset-*` + `source-commit` artifacts to reuse
# (must be within the 7-day artifact retention window).
# release_tag : tag to publish the draft under (e.g. firefox-11).
# ─────────────────────────────────────────────────────────────────────────────
name: republish
on:
workflow_dispatch:
inputs:
build_run_id:
description: 'release run id whose built artifacts to reuse (no rebuild)'
required: true
release_tag:
description: 'release tag to publish the draft under (e.g. firefox-11)'
required: true
env:
SOURCE_REPO: feder-cr/invisible_firefox
jobs:
gate:
name: gate-${{ matrix.leg }}
runs-on: ${{ matrix.runner }}
timeout-minutes: 25
permissions:
actions: read
contents: read
strategy:
fail-fast: false
matrix:
include:
- leg: linux-x86_64
runner: ubuntu-24.04
kind: linux
asset: firefox-150.0.1-stealth-linux-x86_64.tar.gz
extra: '--full'
- leg: linux-arm64
runner: ubuntu-24.04-arm
kind: linux
asset: firefox-150.0.1-stealth-linux-arm64.tar.gz
extra: ''
- leg: win-x86_64
runner: windows-latest
kind: win
asset: firefox-150.0.1-stealth-win-x86_64.zip
extra: ''
- leg: macos-arm64
runner: macos-15
kind: mac
asset: firefox-150.0.1-stealth-macos-arm64.tar.gz
extra: ''
- leg: macos-x86_64
runner: macos-15-intel
kind: mac
asset: firefox-150.0.1-stealth-macos-x86_64.tar.gz
extra: ''
steps:
- name: Checkout wrapper (for scripts/ci_drive_gate.py — the FIXED gate)
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with: { fetch-depth: 1 }
- name: Download asset from the existing build run (no rebuild)
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: asset-${{ matrix.leg }}
path: art
run-id: ${{ github.event.inputs.build_run_id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with: { python-version: '3.11' }
- name: Install Playwright driver (pinned, no bundled browser)
shell: bash
run: python -m pip install --quiet "playwright==$(cat scripts/playwright_pin.txt)"
- name: Linux system deps for headless firefox
if: matrix.kind == 'linux'
run: sudo "$(which python)" -m playwright install-deps firefox
- name: Extract + locate firefox binary
shell: bash
run: |
set -e
mkdir -p ff
A="art/${{ matrix.asset }}"
case "${{ matrix.kind }}" in
win) python -c "import zipfile; zipfile.ZipFile('$A').extractall('ff')"; EXE="ff/firefox.exe";;
linux) tar xzf "$A" -C ff; EXE="ff/firefox";;
mac) tar xzf "$A" -C ff; EXE="ff/Firefox.app/Contents/MacOS/firefox";;
esac
[ -e "$EXE" ] || { echo "ERROR: firefox binary not found at $EXE"; exit 1; }
chmod +x "$EXE" 2>/dev/null || true
echo "FF_EXE=$EXE" >> "$GITHUB_ENV"
echo "located: $EXE"
- name: DRIVE GATE — Playwright launch via juggler + real page (+ interaction on --full)
shell: bash
run: python scripts/ci_drive_gate.py "$FF_EXE" ${{ matrix.extra }}
- name: Install pyobjc Quartz (macOS — to read the cloak window alpha)
if: matrix.kind == 'mac'
run: python -m pip install --quiet pyobjc-framework-Quartz
- name: Cloak + WebGL-masking guards (headed)
shell: bash
run: |
python -m pip install --quiet ".[dev]"
INVPW_BINARY_PATH="$FF_EXE" python -m pytest \
tests/test_cloak.py \
"tests/test_fingerprint_surface.py::test_webgl_readpixels_no_masking_signature" \
-m e2e -o addopts='' -q
publish:
name: publish-draft-release
needs: [gate]
runs-on: ubuntu-24.04
permissions:
actions: read
contents: write
steps:
- name: Checkout wrapper (for scripts/gen_release_notes.py)
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with: { fetch-depth: 1 }
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with: { python-version: '3.11' }
- name: Download all build assets from the existing build run (no rebuild)
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
pattern: asset-*
path: dl
merge-multiple: true
run-id: ${{ github.event.inputs.build_run_id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Download source-commit metadata from the existing build run
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: source-commit
path: src-meta
run-id: ${{ github.event.inputs.build_run_id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Assert all 5 target archives present (no silent partial release)
run: |
cd dl
EXPECTED="
firefox-150.0.1-stealth-linux-x86_64.tar.gz
firefox-150.0.1-stealth-linux-arm64.tar.gz
firefox-150.0.1-stealth-win-x86_64.zip
firefox-150.0.1-stealth-macos-arm64.tar.gz
firefox-150.0.1-stealth-macos-x86_64.tar.gz
"
for a in $EXPECTED; do
[ -s "$a" ] || { echo "ERROR: missing/empty release asset: $a (a build leg silently dropped out?)"; exit 1; }
done
echo "all 5 target archives present"
- name: Generate checksums.txt
run: |
cd dl; ls -la
sha256sum firefox-150.0.1-stealth-* > checksums.txt
echo "----- checksums.txt -----"; cat checksums.txt
- name: Resolve release tag
id: tag
run: |
TAG="${{ github.event.inputs.release_tag }}"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
N="${TAG#firefox-}"
echo "num=$N" >> "$GITHUB_OUTPUT"
case "$N" in (*[!0-9]*|'') echo "prevtag=" >> "$GITHUB_OUTPUT";;
(*) echo "prevtag=firefox-$((N-1))" >> "$GITHUB_OUTPUT";; esac
echo "publishing DRAFT release for tag: $TAG"
- name: Build release notes from the source commits
id: notes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -e
CUR="$(cat src-meta/source-commit.txt 2>/dev/null | tr -d '[:space:]')"
echo "this build's source commit: ${CUR:-<none>}"
PREV=""
PREVTAG="${{ steps.tag.outputs.prevtag }}"
if [ -n "$PREVTAG" ] && gh release download "$PREVTAG" -R "${{ github.repository }}" \
--pattern source-commit.txt --dir prev 2>/dev/null; then
PREV="$(cat prev/source-commit.txt | tr -d '[:space:]')"
echo "previous ($PREVTAG) source commit: $PREV"
else
echo "no previous source-commit.txt — changelog section omitted this time"
fi
python scripts/gen_release_notes.py --tag "${{ steps.tag.outputs.tag }}" \
--current "$CUR" --prev-sha "$PREV" --source-repo "${{ env.SOURCE_REPO }}" > body.md
echo "----- generated body.md -----"; cat body.md
cp src-meta/source-commit.txt dl/source-commit.txt
- name: Create DRAFT release with all assets
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
with:
tag_name: ${{ steps.tag.outputs.tag }}
name: invisible_firefox (150.0.1) rev ${{ steps.tag.outputs.num }}
draft: true
prerelease: false
fail_on_unmatched_files: true
files: |
dl/*.tar.gz
dl/*.zip
dl/checksums.txt
dl/source-commit.txt
body_path: body.md
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View file

@ -1,111 +0,0 @@
# ─────────────────────────────────────────────────────────────────────────────
# verify-assets.yml — re-runnable DRIVE GATE for an EXISTING release's assets.
#
# release.yml drive-gates every binary it builds. This does the same drive test
# WITHOUT rebuilding: it downloads a release's already-published assets (works on
# DRAFT releases too via GITHUB_TOKEN) and drives each one on its native runner.
#
# Use it to:
# • drive-test a release that was built before the in-pipeline gate existed
# (e.g. firefox-9, built on the old release.yml), or
# • re-verify any shipped release on demand (regression check).
#
# Same single-source-of-truth drive logic as release.yml: scripts/ci_drive_gate.py.
# Headless, no screenshot → GPU-free. Zero proxy / zero secrets.
# ─────────────────────────────────────────────────────────────────────────────
name: verify-assets
on:
workflow_dispatch:
inputs:
release_tag:
description: 'release tag whose assets to drive-test (e.g. firefox-9)'
required: true
permissions:
# write (not read) is required: GitHub only exposes DRAFT releases to tokens
# with push access. With contents:read, `gh release download` on a draft tag
# 404s ("release not found"). This workflow only READS assets — the elevated
# scope is solely to make draft releases visible to GITHUB_TOKEN.
contents: write
jobs:
drive:
name: drive-${{ matrix.leg }}
runs-on: ${{ matrix.runner }}
timeout-minutes: 25
strategy:
fail-fast: false
matrix:
include:
# --full (interaction) only on the reliable linux-x86_64 leg; others run
# the robust SMOKE drive. Same rationale as release.yml's gate.
- leg: linux-x86_64
runner: ubuntu-24.04
kind: linux
asset: firefox-150.0.1-stealth-linux-x86_64.tar.gz
extra: '--full'
- leg: linux-arm64
runner: ubuntu-24.04-arm
kind: linux
asset: firefox-150.0.1-stealth-linux-arm64.tar.gz
extra: ''
- leg: win-x86_64
runner: windows-latest
kind: win
asset: firefox-150.0.1-stealth-win-x86_64.zip
extra: ''
- leg: macos-arm64
runner: macos-15
kind: mac
asset: firefox-150.0.1-stealth-macos-arm64.tar.gz
extra: ''
- leg: macos-x86_64
runner: macos-15-intel
kind: mac
asset: firefox-150.0.1-stealth-macos-x86_64.tar.gz
extra: ''
steps:
- name: Checkout wrapper (for scripts/ci_drive_gate.py)
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with: { fetch-depth: 1 }
- name: Download the release asset (draft releases included)
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -e
mkdir -p art
gh release download "${{ github.event.inputs.release_tag }}" \
--repo "${{ github.repository }}" \
--pattern "${{ matrix.asset }}" \
--dir art
ls -la art/
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with: { python-version: '3.11' }
- name: Install Playwright driver (no bundled browser — we override executable_path)
# Single-source pin (see release.yml); the drive gate enforces juggler compat.
shell: bash
run: python -m pip install --quiet "playwright==$(cat scripts/playwright_pin.txt)"
- name: Linux system deps for headless firefox
if: matrix.kind == 'linux'
run: sudo "$(which python)" -m playwright install-deps firefox
- name: Extract + locate firefox binary
shell: bash
run: |
set -e
mkdir -p ff
A="art/${{ matrix.asset }}"
case "${{ matrix.kind }}" in
win) python -c "import zipfile; zipfile.ZipFile('$A').extractall('ff')"; EXE="ff/firefox.exe";;
linux) tar xzf "$A" -C ff; EXE="ff/firefox";;
mac) tar xzf "$A" -C ff; EXE="ff/Firefox.app/Contents/MacOS/firefox";;
esac
[ -e "$EXE" ] || { echo "ERROR: firefox binary not found at $EXE"; exit 1; }
chmod +x "$EXE" 2>/dev/null || true
echo "FF_EXE=$EXE" >> "$GITHUB_ENV"
echo "located: $EXE"
- name: DRIVE GATE — Playwright launch via juggler + real page (+ interaction on --full)
shell: bash
run: python scripts/ci_drive_gate.py "$FF_EXE" ${{ matrix.extra }}

View file

@ -1,103 +0,0 @@
# ─────────────────────────────────────────────────────────────────────────────
# verify-cloak.yml — re-runnable CLOAK + WEBGL-MASKING GUARDS for an EXISTING
# build run's artifacts, WITHOUT rebuilding Firefox (~3h on the mac legs).
#
# release.yml runs these same guards in its `gate` job against each freshly-built
# artifact. This re-runs them against the artifacts of a PRIOR build run (input
# `run_id`) using the CURRENT wrapper code on the default branch — so a test-only
# fix (e.g. making the macOS leg tolerant of the runner's missing WebGL) can be
# validated against the real binaries in ~10 min instead of paying a full rebuild.
#
# Same guard command as release.yml's gate. Headed-but-cloaked; zero proxy / zero
# secrets. The macOS legs are the only place the cocoa cloak patch actually RUNS.
# ─────────────────────────────────────────────────────────────────────────────
name: verify-cloak
on:
workflow_dispatch:
inputs:
run_id:
description: 'build run id whose asset-* artifacts to re-gate (e.g. 27346856197)'
required: true
permissions:
contents: read
actions: read # download-artifact needs this to read another run's artifacts
jobs:
guard:
name: guard-${{ matrix.leg }}
runs-on: ${{ matrix.runner }}
timeout-minutes: 25
strategy:
fail-fast: false
matrix:
# Same legs/runners/assets as release.yml's gate matrix.
include:
- leg: linux-x86_64
runner: ubuntu-24.04
kind: linux
asset: firefox-150.0.1-stealth-linux-x86_64.tar.gz
- leg: linux-arm64
runner: ubuntu-24.04-arm
kind: linux
asset: firefox-150.0.1-stealth-linux-arm64.tar.gz
- leg: win-x86_64
runner: windows-latest
kind: win
asset: firefox-150.0.1-stealth-win-x86_64.zip
- leg: macos-arm64
runner: macos-15
kind: mac
asset: firefox-150.0.1-stealth-macos-arm64.tar.gz
- leg: macos-x86_64
runner: macos-15-intel
kind: mac
asset: firefox-150.0.1-stealth-macos-x86_64.tar.gz
steps:
- name: Checkout wrapper (current default branch — the FIXED tests)
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with: { fetch-depth: 1 }
- name: Download build asset from the prior run (no rebuild)
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: asset-${{ matrix.leg }}
path: art
run-id: ${{ github.event.inputs.run_id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with: { python-version: '3.11' }
- name: Install Playwright driver (no bundled browser — we override executable_path)
# Single-source pin (see release.yml); the wrapper enforces juggler compat.
shell: bash
run: python -m pip install --quiet "playwright==$(cat scripts/playwright_pin.txt)"
- name: Linux system deps for headless firefox
if: matrix.kind == 'linux'
run: sudo "$(which python)" -m playwright install-deps firefox
- name: Extract + locate firefox binary
shell: bash
run: |
set -e
mkdir -p ff
A="art/${{ matrix.asset }}"
case "${{ matrix.kind }}" in
win) python -c "import zipfile; zipfile.ZipFile('$A').extractall('ff')"; EXE="ff/firefox.exe";;
linux) tar xzf "$A" -C ff; EXE="ff/firefox";;
mac) tar xzf "$A" -C ff; EXE="ff/Firefox.app/Contents/MacOS/firefox";;
esac
[ -e "$EXE" ] || { echo "ERROR: firefox binary not found at $EXE"; exit 1; }
chmod +x "$EXE" 2>/dev/null || true
echo "FF_EXE=$EXE" >> "$GITHUB_ENV"
echo "located: $EXE"
- name: Install pyobjc Quartz (macOS — to read the cloak window alpha)
if: matrix.kind == 'mac'
run: python -m pip install --quiet pyobjc-framework-Quartz
- name: Cloak + WebGL-masking guards (headed)
shell: bash
run: |
python -m pip install --quiet ".[dev]"
INVPW_BINARY_PATH="$FF_EXE" python -m pytest \
tests/test_cloak.py \
"tests/test_fingerprint_surface.py::test_webgl_readpixels_no_masking_signature" \
-m e2e -o addopts='' -q

View file

@ -8,11 +8,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
### Added
- `timezone="auto"`: the browser timezone is auto-derived from the egress IP. By default (no explicit timezone) it ALWAYS resolves — from the proxy egress when a proxy is set, otherwise from the host's own public IP — so the zone can never disagree with the IP (the classic `timezone_mismatch` signal). An explicit `"Area/City"` is the only way to force a specific zone. On failure: with a proxy the launch raises (no silent host-TZ fallback behind a foreign proxy); without a proxy it falls back to the host TZ so a transient lookup can't break the launch.
- The egress IP is mapped to its IANA zone with an offline mmdb (`daijro/geoip-all-in-one`). It auto-updates against the upstream weekly rebuild: cached locally, re-checked after `GEOIP_REFRESH_DAYS` (7), older copies pruned, and a stale cache is reused when offline. `STEALTHFOX_GEOIP_MMDB` points at your own `.mmdb` to skip the download.
- The egress IP is mapped to its IANA zone with an offline mmdb (`daijro/geoip-all-in-one`). It always tracks the upstream weekly rebuild: on every launch the current latest release tag is resolved from the `releases/latest/download` permalink (no GitHub API → no rate limit) and pulled only if newer than the cache, older copies pruned. Offline → the cached copy is reused; never a pinned tag (daijro prunes old releases, so a pin eventually 404s). `STEALTHFOX_GEOIP_MMDB` points at your own `.mmdb` to skip the download.
- `resolve_session_timezone(timezone, proxy)` and `ensure_geoip_mmdb()` re-exported at the package root (plus `GeoTimezoneError`) so integrations that own their launch can reproduce the resolution.
- `tests/test_geo.py` (37) + `tests/test_geoip_update.py` (freshness / auto-update / offline fallback) unit tests.
- Cross-OS render parity (needs `firefox-12`): the same font/canvas/WebGL fingerprint now renders consistently on Windows, Linux and macOS, so a Windows persona looks identical regardless of the host the binary runs on. Each whitelisted font renders a distinct canvas image (font-detection probes that dedup by rendered image keep every name), the standard Windows fonts (Calibri, Franklin Gothic, Gadugi, Javanese Text, Myanmar Text) are always present so the detected font set matches a real Windows install, and the per-seed render-noise leaves a solid-colour reference render byte-exact while still varying real fingerprint renders.
- GPU persona applied on every platform: Linux/macOS hosts now present a coherent Windows GPU (renderer + WebGL parameters) instead of the host's real adapter; pool re-rooted on a real-device GPU mix.
- `tests/test_canvas_render_stealth.py`, `tests/test_webgl_noise_active.py` and new `tests/test_sampler.py` cases: regression guards for per-font canvas distinctness, solid-readback purity under render-noise, and the always-present standard-font invariant.
### Changed
- Pins `BINARY_VERSION = firefox-12` (the build with the cross-OS render-parity patches).
- New runtime dependencies: `requests[socks]` (SOCKS egress lookup), `maxminddb` (mmdb reader), `tzdata` (IANA database for `zoneinfo`, which Windows lacks).
## [0.2.0] - 2026-05-28
@ -73,7 +77,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
## [0.1.3] - 2026-05-19
### Changed
- `BINARY_VERSION` bumped from `firefox-2` to `firefox-3`. The new archives on both Windows and Linux are built from a clean clone of [feder-cr/invisible_firefox#stealth/150](https://github.com/feder-cr/invisible_firefox/tree/stealth/150) — the consolidated source-of-truth fork (renamed from `feder-cr/firefox`; the companion `feder-cr/firefox-stealth` patches repo was deleted, all patches now live as commits on top of `mozilla-firefox/firefox`).
- `BINARY_VERSION` bumped from `firefox-2` to `firefox-3`. The new archives on both Windows and Linux are built from a clean clone of [feder-cr/firefox_antidetect_patch#stealth/150](https://github.com/feder-cr/firefox_antidetect_patch/tree/stealth/150) — the consolidated source-of-truth fork (renamed from `feder-cr/firefox`; the companion `feder-cr/firefox-stealth` patches repo was deleted, all patches now live as commits on top of `mozilla-firefox/firefox`).
- The patched Firefox archive now ships the **proper C++ implementation** of `windowUtils.jugglerSendMouseEvent`, replacing the JS shim from 0.1.2.
### C++ fixes landed in this release
@ -84,7 +88,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
- **C7 (partial)**: storage stub for `nsIDocShell.languageOverride`. Workaround `InvisiblePlaywright(locale="")` recommended until full BC FIELD port lands.
### Verified
- Both archives built from same source: feder-cr/invisible_firefox commit `68906f1f9c55`.
- Both archives built from same source: feder-cr/firefox_antidetect_patch commit `68906f1f9c55`.
- Windows + Linux smoke suite green: launch, `ctx.new_page()`, `page.mouse.{move,down,up,click,wheel}`, `navigator.webdriver=false`, sannysoft 32/33 PASS.
- SHA256 published in `checksums.txt` on the `firefox-3` release.

View file

@ -7,7 +7,7 @@ Thanks for your interest in improving this project. Contributions are welcome vi
- **Bug?** Open a [bug report](https://github.com/feder-cr/invisible_playwright/issues/new?template=bug_report.yml).
- **Idea?** Open a [feature request](https://github.com/feder-cr/invisible_playwright/issues/new?template=feature_request.yml).
- **Security issue?** Do **not** open a public issue — see [SECURITY.md](SECURITY.md).
- **The C++ patches** live in the companion repo [feder-cr/invisible_firefox](https://github.com/feder-cr/invisible_firefox) (branch `stealth/150`). Bugs in fingerprint spoofing usually belong there.
- **The C++ patches** live in the companion repo [feder-cr/firefox_antidetect_patch](https://github.com/feder-cr/firefox_antidetect_patch) (branch `stealth/150`). Bugs in fingerprint spoofing usually belong there.
## Scope
@ -18,7 +18,7 @@ This repository ships the **Python wrapper** (`invisible_playwright`) around a p
- Binary download/caching, CLI, proxy plumbing
- Tests, docs, examples, packaging
Out of scope (belongs in `invisible_firefox`):
Out of scope (belongs in `firefox_antidetect_patch`):
- Changes to the Firefox C++ source
- New preferences exposed by the patched binary
@ -65,7 +65,7 @@ Before opening, please:
- Search [existing issues](https://github.com/feder-cr/invisible_playwright/issues) — the bug may already be tracked.
- Reproduce on the **latest release** if possible.
- Confirm the issue is in the Python wrapper, not the patched Firefox itself. If a fingerprint is leaking or a detector flags the browser, open the issue at `feder-cr/invisible_firefox` instead.
- Confirm the issue is in the Python wrapper, not the patched Firefox itself. If a fingerprint is leaking or a detector flags the browser, open the issue at `feder-cr/firefox_antidetect_patch` instead.
Include:

108
README.md
View file

@ -1,53 +1,45 @@
# invisible_playwright
<p>
<a href="https://github.com/feder-cr/invisible_playwright/actions/workflows/tests.yml"><img src="https://github.com/feder-cr/invisible_playwright/actions/workflows/tests.yml/badge.svg" alt="tests"></a>
<a href="LICENSE"><img src="docs/badges/license.svg" alt="License: MIT"></a>
<a href="https://www.python.org/downloads/"><img src="docs/badges/python.svg" alt="Python 3.11+"></a>
<a href="https://github.com/feder-cr/firefox_antidetect_patch/releases"><img src="docs/badges/firefox.svg" alt="Firefox 150.0.1"></a>
<a href="https://github.com/feder-cr/invisible_playwright/stargazers"><img src="docs/badges/stars.svg" alt="GitHub stars"></a>
<a href="https://github.com/feder-cr/firefox_antidetect_patch/releases/tag/usage-counter"><img src="docs/badges/launches.svg" alt="browser launches"></a>
</p>
[![tests](https://github.com/feder-cr/invisible_playwright/actions/workflows/tests.yml/badge.svg)](https://github.com/feder-cr/invisible_playwright/actions/workflows/tests.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
[![Firefox 150.0.1](https://img.shields.io/badge/firefox-150.0.1-orange.svg)](https://www.mozilla.org/firefox/)
[![GitHub release](https://img.shields.io/github/v/release/feder-cr/invisible_playwright.svg)](https://github.com/feder-cr/invisible_playwright/releases)
[![GitHub stars](https://img.shields.io/github/stars/feder-cr/invisible_playwright.svg?style=social)](https://github.com/feder-cr/invisible_playwright/stargazers)
[![browser launches](https://img.shields.io/github/downloads/feder-cr/invisible_firefox/usage-counter/total?label=browser%20launches&color=blue)](https://github.com/feder-cr/invisible_firefox/releases/tag/usage-counter)
<div align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/banner-dark.png">
<img src="docs/banner-light.png" alt="invisible_playwright" width="620">
</picture>
</div>
[![LinkedIn](https://img.shields.io/badge/LinkedIn-Federico%20Elia-0A66C2?logo=linkedin&logoColor=white)](https://it.linkedin.com/in/federico-elia-5199951b6)
**Stealth Firefox that passes every bot detection test. Drop-in Playwright replacement, fingerprint patched at the C++ level, not a JavaScript shim.**
<h3 align="center">A modified Firefox that passes every bot detection test.</h3>
![invisible_playwright - 5/5 detection suites passed](docs/screenshots/hero.gif)
## How it works
## Why it's powerful
Anti-bots ask two questions. invisible_playwright answers yes to both.
**1. Is this a real browser?** Yes. It is Firefox, patched at the C++ source level.
**Most other anti-detect browsers patch Chromium at the JavaScript level** - they override `navigator`, `WebGLRenderingContext.getParameter`, canvas APIs, and so on via injected scripts. This has two fatal problems:
- Fingerprint set inside the engine, not injected into the page: Navigator, screen, GPU/WebGL, Canvas, fonts, audio, WebRTC, timezone, network.
- No JS shim, no override, no seam to read.
1. **JS patches are detectable.** Anti-bots enumerate native function `.toString()`, check descriptor configurability, compare property enumeration order, watch for prototype mutations. Every patch leaves a fingerprint of its own. CreepJS has an entire battery of "lies detectors" built around this.
2. **Chromium itself is now suspect.** Residential-proxy bot traffic is overwhelmingly Chromium-based, so detectors weight anything Chromium-shaped as risky by default. Chromium-based forks inherit Chrome's open-source layers (BoringSSL, Blink, V8, ANGLE) cleanly, but they still cannot fully match Chrome in practice: Chrome ships closed-source components on top (Widevine, proprietary codecs, Google Update / Safe Browsing endpoints) that flip detectable JS feature flags and network signals, and forks lag Chrome's release cadence by days to weeks, leaving telltale version-specific behaviours that detectors lock onto.
**2. Is a real person using it?** Yes. The actions are humanized in the driver.
**invisible_playwright patches Firefox at the C++ level.** The spoofed values come back out through the normal Gecko paths - there is no JS shim, no override, no `Object.defineProperty`. **From the page's point of view, the browser is just telling the truth.** Anti-bot lie-detectors have nothing to latch onto.
- Every click, hover and drag follows a natural mouse path with human timing, no teleporting cursor.
- Each input is byte-identical to a real mouse: real input source, pressure, trusted events.
invisible_playwright spoofs **all the layers that matter, together, coherently**: Navigator, screen, GPU/WebGL, Canvas, fonts, audio, WebRTC, timezone, DevTools detection, SOCKS5 auth, and the rest. See [feder-cr/invisible_firefox](https://github.com/feder-cr/invisible_firefox) for the full per-layer breakdown of which C++ files are patched and why.
Everything is driven by preferences - no hardcoded values in the binary. You change one pref, you change the spoofed value.
Driven by the standard Playwright API. Full breakdown: [feder-cr/firefox_antidetect_patch](https://github.com/feder-cr/firefox_antidetect_patch).
---
## How it compares
## Still seeing captchas or anti-bot? It's the proxy.
Once the browser is handled it stops being the variable. If you are still getting challenged, the tell is no longer the browser, it is the IP you come from. Around 90% of proxies are public: anyone can rent the same address, so it is already known and sits on the blocked-IP lists sites check. A perfect browser on a known IP still loses.
**CloakBrowser** ships a similar pitch for Chromium, but its binary is **closed source** (the source-level patches are not published, you only get the compiled output), and it still hits the Chromium reCAPTCHA ceiling. The commercial anti-detect browsers (**Multilogin**, **GoLogin**, AdsPower, Dolphin, Kameleo) are paid SaaS that overlay JS-layer spoofing on a patched Chromium. Managed profiles are nice but raw detection bypass sits below both Camoufox and us.
| | invisible_playwright | Camoufox | CloakBrowser | Multilogin |
|---|---|---|---|---|
| Engine | Firefox 150 | Firefox (~1 year old base) | Chromium | Chromium fork |
| Patch depth | C++ source | C++ source | C++ source | JS overrides |
| Maintenance | Active | Gap (~1 year) | Active | Active SaaS |
| Open source | ✅ MIT | ✅ MPL | ❌ Closed source | ❌ Closed source |
| `.toString()` clean | ✅ | ✅ | ✅ | ❌ Detectable shims |
| Canvas / WebGL / Audio | ✅ C++ | ⚠️ Drift vs current FF | ✅ C++ | ⚠️ JS override |
| SOCKS5 auth | ✅ Patched | ❌ | ⚠️ Playwright proxy | ⚠️ Varies |
| **reCAPTCHA v3 score** | **0.90** | ~0.3-0.5 | ~0.3-0.5 | ~0.3-0.6 |
| FP Pro - bot detected | ✅ Not detected | ❌ Detected | ❌ Detected | ❌ Detected |
| CreepJS lies | ✅ 0 | ❌ Multiple | ✅ 0 | ❌ Multiple |
| Cost | Free | Free | Free | From $99/mo |
> The fix is the clean 10%, residential IPs that aren't already known. For those we recommend [sx.org](https://sx.org/?c=invisible_playwright), who filter for and serve only IPs that aren't already on those lists.
---
@ -74,7 +66,7 @@ Supported platforms: **Windows x86_64**, **Linux x86_64 / arm64**, **macOS arm64
+ with InvisiblePlaywright() as browser:
```
Every session gets a unique, coherent fingerprint drawn from real-world Firefox telemetry (GPU / audio / fonts / ~400 other fields) and Bezier-curve mouse motion baked into the browser itself.
Every session gets a distinct fingerprint (GPU, audio, fonts, screen, ~400 fields) and Bezier-curve mouse motion.
**Sync**
```python
@ -98,19 +90,7 @@ async with InvisiblePlaywright(proxy={"server": "socks5://...", "username": "u",
The `browser` object is a `playwright.sync_api.Browser` / `playwright.async_api.Browser` - every Playwright method works as-is.
---
### Random fingerprint per session
```python
from invisible_playwright import InvisiblePlaywright
with InvisiblePlaywright() as browser:
page = browser.new_page()
page.goto("https://creepjs-api.web.app")
```
Every call samples a new coherent profile. Log the seed to reproduce interesting runs:
Log the seed to replay a run:
```python
sf = InvisiblePlaywright()
@ -138,7 +118,9 @@ with InvisiblePlaywright(proxy=proxy) as browser:
...
```
Schemes supported: `socks5`, `socks4`, `http`, `https`. Auth works on all of them (SOCKS5 via patched `nsProtocolProxyService.cpp`, HTTP/HTTPS via Playwright). DNS is routed through the proxy by default, no local leak.
Schemes supported: `socks5`, `socks4`, `http`, `https`. DNS is routed through the proxy by default, no local leak.
Around 90% of proxies are public, so their IPs are already known and blocked. For the clean 10%, residential IPs that aren't already known, we recommend [sx.org](https://sx.org/?c=invisible_playwright), who filter for and serve only IPs that aren't already on those lists.
### Timezone
@ -150,7 +132,7 @@ The browser timezone follows `timezone=`:
with InvisiblePlaywright(proxy=proxy) as browser:
...
# explicit IANA zone always wins the only way to force a specific zone
# explicit IANA zone always wins, the only way to force a specific zone
with InvisiblePlaywright(proxy=proxy, timezone="America/New_York") as browser:
...
```
@ -189,14 +171,28 @@ invisible_playwright clear-cache # remove all cached binaries
## Related projects
invisible_playwright takes a different angle than the major Firefox-hardening projects but stands on their shoulders:
Related projects that cover similar ground:
- **[arkenfox/user.js](https://github.com/arkenfox/user.js)** - the canonical Firefox configuration for privacy/security hardening via prefs. Reading arkenfox is how you understand which `user.js` knobs matter; invisible_playwright goes further by patching the C++ source where prefs alone are insufficient (Canvas noise, WebGL parameter overrides, font whitelisting, WebRTC IP swap, DevTools detection bypass).
- **[LibreWolf](https://librewolf.net)** - a Firefox fork bundled with sensible privacy defaults. Same audience, different distribution model: LibreWolf ships a configured Firefox binary, invisible_playwright ships source patches + a wrapper for automation.
- **[Camoufox](https://github.com/daijro/camoufox)** - the most well-known open-source anti-detect Firefox project. We share design goals on the fingerprint-spoofing side; the implementation approach differs (Camoufox patches a wider surface and ships its own fingerprint database, while invisible_playwright sticks closer to vanilla and drives spoofing from a Bayesian sampler).
- **[arkenfox/user.js](https://github.com/arkenfox/user.js)** - Firefox privacy hardening via prefs. invisible_playwright patches C++ where prefs are insufficient.
- **[LibreWolf](https://librewolf.net)** - Firefox fork with privacy defaults. LibreWolf ships a configured binary; invisible_playwright ships source patches + automation wrapper.
- **[Camoufox](https://github.com/daijro/camoufox)** - open-source anti-detect Firefox. Patches a wider surface and ships its own fingerprint database; invisible_playwright uses a Bayesian sampler.
---
## License
MIT - see [LICENSE](LICENSE). The patched Firefox binary is distributed under the MPL-2.0 (Firefox upstream license). The C++ patches against mozilla-central that produce that binary are at [feder-cr/invisible_firefox](https://github.com/feder-cr/invisible_firefox).
MIT - see [LICENSE](LICENSE). The patched Firefox binary is distributed under the MPL-2.0 (Firefox upstream license). The C++ patches against mozilla-central that produce that binary are at [feder-cr/firefox_antidetect_patch](https://github.com/feder-cr/firefox_antidetect_patch).
---
## Disclaimer
This project is for educational purposes only. It is provided as-is, with no warranties. I take no responsibility for how it is used. Use it at your own risk and in compliance with the laws of your jurisdiction.
---
<p align="center">
Built by <a href="https://it.linkedin.com/in/federico-elia-5199951b6">Federico Elia</a>
&nbsp;<a href="https://it.linkedin.com/in/federico-elia-5199951b6"><img src="docs/badges/linkedin.svg" alt="LinkedIn"></a>
</p>

View file

@ -1,8 +1,12 @@
# Security Policy
## Disclaimer
This is an educational project. It is provided as-is, with no warranties. The maintainers take no responsibility for how it is used. Use it at your own risk and in compliance with the laws of your jurisdiction.
## Supported versions
Only the latest release on `main` receives security fixes.
Only the latest release on `main` receives fixes.
| Version | Supported |
|---------|-----------|
@ -11,44 +15,33 @@ Only the latest release on `main` receives security fixes.
## Reporting a vulnerability
**Please do not report security issues via public GitHub issues, discussions, or pull requests.**
Do not report security issues via public GitHub issues, discussions, or pull requests.
Use one of the following private channels:
Send a report to `federico.elia.majo@gmail.com` with subject prefix `[security][invisible_playwright]`.
1. **GitHub Private Vulnerability Reporting** (preferred): open an advisory at https://github.com/feder-cr/invisible_playwright/security/advisories/new
2. **Email**: `federico.elia.majo@gmail.com` with subject prefix `[security][invisible_playwright]`
Include:
Please include:
- A clear description of the issue and impact
- Steps to reproduce (minimal repro preferred)
- The version of `invisible_playwright` and OS where it was observed
- Whether you have a suggested fix
## What to expect
- Acknowledgement of your report within **7 days**
- An initial assessment and tracking issue (private) within **14 days**
- Coordinated disclosure: a fix and public advisory are released together; reporters are credited unless they prefer to remain anonymous
- What the issue is and what it affects
- Steps to reproduce
- Version of `invisible_playwright` and OS
- Fix suggestion if you have one
## Scope
In scope:
- The Python wrapper `invisible_playwright` (this repo)
- The binary download/verification flow (SHA256 pinning, fetch endpoints)
- The Python wrapper (this repo)
- The binary download and verification flow
- The CLI
Out of scope here (report to the relevant project):
Out of scope:
- Vulnerabilities in the patched Firefox C++ source — open a private report at [feder-cr/invisible_firefox](https://github.com/feder-cr/invisible_firefox/security/advisories/new)
- Vulnerabilities in upstream Firefox / mozilla-central — report to Mozilla per https://www.mozilla.org/security/
- Vulnerabilities in third-party dependencies (`playwright`, `requests`, etc.) — report to those projects directly
- Vulnerabilities in the patched Firefox source — report to [feder-cr/firefox_antidetect_patch](https://github.com/feder-cr/firefox_antidetect_patch/security/advisories/new)
- Upstream Firefox / mozilla-central — report to Mozilla directly
- Third-party dependencies — report to those projects
## Out of scope
Not security issues:
- Reports that the browser is detected by a specific anti-bot service — open a regular GitHub issue, this is a product-quality concern, not a security one
- Social engineering of maintainers
- Denial of service requiring physical access or local privileged access
Thank you for helping keep the project and its users safe.
- The browser being detected by an anti-bot service — open a regular issue
- Social engineering
- DoS requiring physical or local privileged access

16
docs/badges/firefox.svg Normal file
View file

@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" width="112" height="20" role="img" aria-label="Firefox: 150.0.1">
<title>Firefox: 150.0.1</title>
<linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient>
<clipPath id="r"><rect width="112" height="20" rx="3" fill="#fff"/></clipPath>
<g clip-path="url(#r)">
<rect width="56" height="20" fill="#555"/>
<rect x="56" width="56" height="20" fill="#FF7139"/>
<rect width="112" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
<text x="28.0" y="15" fill="#010101" fill-opacity=".3">Firefox</text>
<text x="28.0" y="14">Firefox</text>
<text x="84.0" y="15" fill="#010101" fill-opacity=".3">150.0.1</text>
<text x="84.0" y="14">150.0.1</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 866 B

16
docs/badges/launches.svg Normal file
View file

@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" width="139" height="20" role="img" aria-label="browser launches: 1M">
<title>browser launches: 1M</title>
<linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient>
<clipPath id="r"><rect width="139" height="20" rx="3" fill="#fff"/></clipPath>
<g clip-path="url(#r)">
<rect width="116" height="20" fill="#555"/>
<rect x="116" width="23" height="20" fill="#007ec6"/>
<rect width="139" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
<text x="58.0" y="15" fill="#010101" fill-opacity=".3">browser launches</text>
<text x="58.0" y="14">browser launches</text>
<text x="127.5" y="15" fill="#010101" fill-opacity=".3">1M</text>
<text x="127.5" y="14">1M</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 886 B

16
docs/badges/license.svg Normal file
View file

@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" width="86" height="20" role="img" aria-label="License: MIT">
<title>License: MIT</title>
<linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient>
<clipPath id="r"><rect width="86" height="20" rx="3" fill="#fff"/></clipPath>
<g clip-path="url(#r)">
<rect width="56" height="20" fill="#555"/>
<rect x="56" width="30" height="20" fill="#dfb317"/>
<rect width="86" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
<text x="28.0" y="15" fill="#010101" fill-opacity=".3">License</text>
<text x="28.0" y="14">License</text>
<text x="71.0" y="15" fill="#010101" fill-opacity=".3">MIT</text>
<text x="71.0" y="14">MIT</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 847 B

16
docs/badges/linkedin.svg Normal file
View file

@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" width="159" height="20" role="img" aria-label="LinkedIn: Federico Elia">
<title>LinkedIn: Federico Elia</title>
<linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient>
<clipPath id="r"><rect width="159" height="20" rx="3" fill="#fff"/></clipPath>
<g clip-path="url(#r)">
<rect width="63" height="20" fill="#555"/>
<rect x="63" width="96" height="20" fill="#0A66C2"/>
<rect width="159" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
<text x="31.5" y="15" fill="#010101" fill-opacity=".3">LinkedIn</text>
<text x="31.5" y="14">LinkedIn</text>
<text x="111.0" y="15" fill="#010101" fill-opacity=".3">Federico Elia</text>
<text x="111.0" y="14">Federico Elia</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 896 B

16
docs/badges/python.svg Normal file
View file

@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" width="93" height="20" role="img" aria-label="python: 3.11+">
<title>python: 3.11+</title>
<linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient>
<clipPath id="r"><rect width="93" height="20" rx="3" fill="#fff"/></clipPath>
<g clip-path="url(#r)">
<rect width="50" height="20" fill="#555"/>
<rect x="50" width="43" height="20" fill="#007ec6"/>
<rect width="93" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
<text x="25.0" y="15" fill="#010101" fill-opacity=".3">python</text>
<text x="25.0" y="14">python</text>
<text x="71.5" y="15" fill="#010101" fill-opacity=".3">3.11+</text>
<text x="71.5" y="14">3.11+</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 851 B

16
docs/badges/stars.svg Normal file
View file

@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" width="79" height="20" role="img" aria-label="Stars: 1.8k">
<title>Stars: 1.8k</title>
<linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient>
<clipPath id="r"><rect width="79" height="20" rx="3" fill="#fff"/></clipPath>
<g clip-path="url(#r)">
<rect width="43" height="20" fill="#555"/>
<rect x="43" width="36" height="20" fill="#e8a317"/>
<rect width="79" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
<text x="21.5" y="15" fill="#010101" fill-opacity=".3">Stars</text>
<text x="21.5" y="14">Stars</text>
<text x="61.0" y="15" fill="#010101" fill-opacity=".3">1.8k</text>
<text x="61.0" y="14">1.8k</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 843 B

BIN
docs/banner-dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

BIN
docs/banner-light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

View file

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "invisible-playwright"
version = "0.2.0"
version = "0.3.1"
description = "Playwright wrapper for a patched Firefox with deterministic stealth profile."
readme = "README.md"
requires-python = ">=3.11"
@ -20,17 +20,18 @@ classifiers = [
"Programming Language :: Python :: 3.12",
]
dependencies = [
"playwright>=1.40",
"platformdirs>=4",
"requests[socks]>=2.31",
"maxminddb>=2.2",
"tzdata>=2024.1",
"tqdm>=4.66",
"pywin32>=306; sys_platform == 'win32'",
# Pure config (seed -> fingerprint -> prefs, binary download, proxy, geo) lives
# in invisible-core since the 2026-07-03 split; it pulls platformdirs/requests/
# maxminddb/tzdata/tqdm/pywin32 transitively. The wrapper only adds Playwright.
"invisible-core @ git+https://github.com/feder-cr/invisible_core.git",
# Pinned to the Juggler client we actually test against (scripts/playwright_pin.txt,
# 1.55.x). Playwright 1.61 added an isMobile field to Browser.setDefaultViewport that
# the FF150 Juggler does not accept, which breaks the session (issue #48).
"playwright>=1.55,<1.56",
]
[project.optional-dependencies]
dev = ["pytest>=7", "pytest-mock>=3", "responses>=0.24", "build>=1", "pytest-rerunfailures>=14", "playwright>=1.40"]
dev = ["pytest>=7", "pytest-mock>=3", "responses>=0.24", "build>=1", "pytest-rerunfailures>=14", "playwright>=1.55,<1.56"]
[tool.pytest.ini_options]
markers = [
@ -55,8 +56,15 @@ invisible-playwright = "invisible_playwright.cli:main"
Homepage = "https://github.com/feder-cr/invisible_playwright"
Issues = "https://github.com/feder-cr/invisible_playwright/issues"
[tool.hatch.metadata]
# invisible-core is pinned by a direct git reference (see [project].dependencies).
# Hatchling refuses direct references unless this is opted in — without it a clean
# `pip install git+.../invisible_playwright` fails at metadata generation.
allow-direct-references = true
[tool.hatch.build.targets.wheel]
packages = ["src/invisible_playwright"]
exclude = ["*.bak", "*-bak"]
[tool.hatch.build.targets.sdist]
include = ["src/invisible_playwright", "tests", "README.md", "LICENSE", "pyproject.toml"]

170
scripts/ci_font_gate.py Normal file
View file

@ -0,0 +1,170 @@
#!/usr/bin/env python3
"""CI font gate - assert the patched binary exposes exactly the Windows font
persona on EVERY host OS (Windows / Linux / macOS), with zero host-font leak.
The patched binary is bundle-only: at font-list construction it drops every host
system font and exposes only the bundled Windows-11 family set (the exposed set
IS the bundle). This gate launches the binary on its NATIVE runner - so
macOS/CoreText, Linux/fontconfig and Windows/DWrite are each tested for real -
enumerates the visible families with the same width-probe web detectors use, and
asserts three things:
1. the detected family set == the canonical Windows set (EXPECTED): the SAME
set on all three platforms. A leaked host font or a missing Windows one
fails here. This is the "identical on every OS" contract.
2. no known host family is visible (macOS: Helvetica Neue / Geneva / Menlo ...;
Linux: DejaVu / Ubuntu ...) - a POSITIVE proof that block-at-birth ran for
this platform's backend, not just "no obvious tell".
3. the CSS generics resolve to Windows fonts (serif=Times New Roman,
sans-serif=Arial, monospace=Consolas) and system-ui=Segoe UI.
This is the macOS validator the local Win/Linux gate cannot be - there is no
local Mac, so CoreText is only ever exercised here. Headless, no proxy, no
secrets, loopback-free (about:blank + arrow-function evaluate, which is not
eval and carries no CSP problem) -> safe in public CI.
Usage: python ci_font_gate.py <firefox-binary>
Exit 0 + "FONT GATE OK ..." on success; non-zero + the diff on failure.
"""
from __future__ import annotations
import sys
# The canonical Windows-11 family set the bundle exposes. Verified byte-for-byte
# identical on Windows/DWrite and Linux/fontconfig; macOS/CoreText must match it
# too. Keep this sorted and in sync with the bundle (browser/fonts/moz.build).
EXPECTED = [
"Arial", "Arial Black", "Bahnschrift", "Calibri", "Cambria", "Cambria Math",
"Candara", "Comic Sans MS", "Consolas", "Constantia", "Corbel",
"Courier New", "Ebrima", "Franklin Gothic", "Franklin Gothic Medium",
"Gabriola", "Gadugi", "Georgia", "Impact", "Ink Free", "Javanese Text",
"Leelawadee", "Leelawadee UI", "Lucida Console", "Lucida Sans Unicode",
"MS Gothic", "MS PGothic", "MS UI Gothic", "MV Boli", "Malgun Gothic",
"Marlett", "Microsoft Himalaya", "Microsoft JhengHei",
"Microsoft JhengHei UI", "Microsoft New Tai Lue", "Microsoft PhagsPa",
"Microsoft Sans Serif", "Microsoft Tai Le", "Microsoft Uighur",
"Microsoft YaHei", "Microsoft YaHei UI", "Microsoft Yi Baiti",
"MingLiU-ExtB", "Mongolian Baiti", "Myanmar Text", "NSimSun", "Nirmala UI",
"PMingLiU-ExtB", "Palatino Linotype", "Segoe Fluent Icons",
"Segoe MDL2 Assets", "Segoe Print", "Segoe Script", "Segoe UI",
"Segoe UI Emoji", "Segoe UI Historic", "Segoe UI Symbol", "SimSun",
"SimSun-ExtB", "Sitka Small", "Sylfaen", "Symbol", "Tahoma",
"Times New Roman", "Trebuchet MS", "Verdana", "Webdings", "Wingdings",
"Wingdings 2", "Wingdings 3", "Yu Gothic", "Yu Gothic UI",
]
# Host families that must NEVER be visible - one per backend. Their presence is a
# hard fail (block-at-birth did not run for this OS). These are decoys added to
# the probe list; they must all come back absent.
HOST_MUST_BE_ABSENT = [
# macOS / CoreText
"Helvetica Neue", "Geneva", "Menlo", "Monaco", "Avenir", "Lucida Grande",
"Apple SD Gothic Neo", "PingFang SC",
# Linux / fontconfig
"DejaVu Sans", "Liberation Sans", "Ubuntu", "Nimbus Sans", "Noto Sans",
# Office / non-standard families intentionally dropped from the bundle
"Century Gothic", "Agency FB", "Monotype Corsiva", "Pristina",
]
# CSS generic -> the Windows family it must resolve to under bundle-only.
GENERICS = {
"serif": "Times New Roman",
"sans-serif": "Arial",
"monospace": "Consolas",
"system-ui": "Segoe UI",
}
# Width+height probe (the offsetWidth method real detectors use): a family is
# "present" if styling text in it renders at a different size than the three CSS
# base generics. For the generics, return the measured size of each generic and
# of its target Windows family so the caller can assert they coincide.
DETECT_JS = r"""(arg) => {
const bases = ['monospace', 'sans-serif', 'serif'];
const sample = 'mmmmmmmmmmlli WwQ 0123456789 gjpqy';
const sp = document.createElement('span');
sp.style.cssText =
'position:absolute;left:-9999px;font-size:72px;white-space:nowrap;';
sp.textContent = sample;
document.body.appendChild(sp);
const size = (ff) => { sp.style.fontFamily = ff; return sp.offsetWidth + 'x' + sp.offsetHeight; };
const bw = {};
for (const b of bases) bw[b] = size(b);
const present = {};
for (const f of arg.cands) {
present[f] = bases.some((b) => size("'" + f + "'," + b) !== bw[b]);
}
const gen = {};
for (const g of arg.generics) gen[g] = size(g);
const genref = {};
for (const w of arg.targets) genref[w] = size("'" + w + "'");
document.body.removeChild(sp);
return { present, gen, genref };
}"""
# Suppress the new-tab machinery so the launch is quiet (mirrors ci_drive_gate).
_PREFS = {
"browser.startup.page": 0,
"browser.newtabpage.enabled": False,
"browser.newtab.preload": False,
"browser.newtabpage.activity-stream.enabled": False,
}
def main(exe: str) -> int:
from playwright.sync_api import sync_playwright
cands = EXPECTED + HOST_MUST_BE_ABSENT
arg = {
"cands": cands,
"generics": list(GENERICS.keys()),
"targets": list(GENERICS.values()),
}
with sync_playwright() as p:
browser = p.firefox.launch(executable_path=exe, headless=True,
firefox_user_prefs=_PREFS)
try:
page = browser.new_page()
page.goto("about:blank")
r = page.evaluate(DETECT_JS, arg)
finally:
browser.close()
detected = {f for f, v in r["present"].items() if v}
expected = set(EXPECTED)
missing = sorted(expected - detected)
# Anything detected that isn't in EXPECTED (host leaks land here too).
extra = sorted(detected - expected)
leaked_host = [h for h in HOST_MUST_BE_ABSENT if r["present"].get(h)]
gen_bad = []
for g, want in GENERICS.items():
got, ref = r["gen"].get(g), r["genref"].get(want)
if got != ref:
gen_bad.append(f"{g} -> {got} (expected {want} = {ref})")
n = len(detected)
print(f"[font-gate] {exe}")
print(f"[font-gate] detected {n} families (expected {len(EXPECTED)})")
if missing:
print(f"[font-gate] MISSING (in bundle, not exposed): {missing}")
if extra:
print(f"[font-gate] UNEXPECTED (exposed, not in canonical set): {extra}")
if leaked_host:
print(f"[font-gate] HOST LEAK (block-at-birth did not run!): {leaked_host}")
if gen_bad:
print(f"[font-gate] GENERIC MISMATCH: {gen_bad}")
ok = not missing and not extra and not leaked_host and not gen_bad
if ok:
print(f"FONT GATE OK - exactly the {n} Windows families, host-leak 0, "
f"generics map to Windows (serif/sans/mono/system-ui).")
return 0
print("FONT GATE FAILED - the exposed set does not match the Windows "
"persona on this OS (see the diff above).")
return 1
if __name__ == "__main__":
if len(sys.argv) < 2:
print("usage: python ci_font_gate.py <firefox-binary>", file=sys.stderr)
raise SystemExit(2)
raise SystemExit(main(sys.argv[1]))

View file

@ -1,9 +1,9 @@
#!/usr/bin/env python3
"""Generate the GitHub release body for a firefox-N build from the actual
invisible_firefox commits that went into it.
firefox_antidetect_patch commits that went into it.
The release tag (firefox-N) lives on the wrapper, but the binary's changes live
on the SOURCE repo (feder-cr/invisible_firefox). We never deep-clone that history
on the SOURCE repo (feder-cr/firefox_antidetect_patch). We never deep-clone that history
(it's a full Firefox fork); instead we use GitHub's compare API to list the
commits between the PREVIOUS release's source commit and this one, and turn their
subject lines into a short human-readable "What changed" list.
@ -20,7 +20,7 @@ subjects as plain bullets. Quality rides on writing good commit subjects.
Usage:
python scripts/gen_release_notes.py --tag firefox-10 --current <sha> \
[--prev-sha <sha>] [--source-repo feder-cr/invisible_firefox]
[--prev-sha <sha>] [--source-repo feder-cr/firefox_antidetect_patch]
# reads GITHUB_TOKEN from the env for the compare API (optional for public).
"""
from __future__ import annotations
@ -92,16 +92,16 @@ def build_body(tag: str, current_sha: str, bullets: list[str]) -> str:
"`xattr -dr com.apple.quarantine Firefox.app`",
]
if short:
parts += ["", f"Built from invisible_firefox @{short}."]
parts += ["", f"Built from firefox_antidetect_patch @{short}."]
return "\n".join(parts)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--tag", required=True, help="release tag, e.g. firefox-10")
ap.add_argument("--current", required=True, help="invisible_firefox SHA this build was built from")
ap.add_argument("--current", required=True, help="firefox_antidetect_patch SHA this build was built from")
ap.add_argument("--prev-sha", default="", help="previous release's source SHA (omit for none)")
ap.add_argument("--source-repo", default="feder-cr/invisible_firefox")
ap.add_argument("--source-repo", default="feder-cr/firefox_antidetect_patch")
args = ap.parse_args()
token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")

View file

@ -1,26 +1,17 @@
"""Internal Bayesian fingerprint generator used by invisible_playwright.
"""Backward-compat shim — spostato in invisible_core._fpforge (alias completo).
Private module do not import from user code. Use
invisible_playwright.InvisiblePlaywright(seed=..., pin=...) instead.
Aliasa il package E i suoi submodule agli stessi oggetti del core, cosi'
``from invisible_playwright._fpforge.profile import Profile`` e
``from invisible_core._fpforge.profile import Profile`` sono la STESSA classe
(isinstance funziona) e i nomi privati restano accessibili.
"""
from .profile import (
AudioProfile,
CodecProfile,
GPUProfile,
HardwareProfile,
Profile,
ScreenProfile,
WebGLProfile,
generate_profile,
)
import sys as _sys
import invisible_core._fpforge as _pkg
from invisible_core._fpforge import profile as _profile
from invisible_core._fpforge import _sampler as _sampler_mod
from invisible_core._fpforge import _network as _network_mod
__all__ = [
"generate_profile",
"Profile",
"GPUProfile",
"ScreenProfile",
"HardwareProfile",
"AudioProfile",
"CodecProfile",
"WebGLProfile",
]
_sys.modules[__name__] = _pkg
_sys.modules[__name__ + ".profile"] = _profile
_sys.modules[__name__ + "._sampler"] = _sampler_mod
_sys.modules[__name__ + "._network"] = _network_mod

View file

@ -1,144 +0,0 @@
# -*- coding: utf-8 -*-
"""Generic Bayesian network for fingerprint sampling.
A Node has:
- name
- parents (list of parent node names)
- CPT: either
* marginal (no parents): flat [{value, prob}, ...]
* conditional: {parent_tuple: [{value, prob}, ...]}
- OR deterministic: a classifier function `(context) -> value` (no CPT)
Sampling:
- Nodes are topologically sorted
- For each node, look up the conditional distribution given parent values
already sampled in `context`, then weighted-pick
- Deterministic nodes apply their classifier directly
Values can be ANY JSON-serializable type (int, str, dict, list, bool).
Complex values (e.g. screen joint {w, h, dpr}) are stored as dicts in the
CPT and returned as-is in the context.
"""
import json
import random
from typing import Any, Callable, Dict, List, Optional, Tuple
class Node:
"""Single Bayesian node."""
__slots__ = ("name", "parents", "cpt", "classifier", "_marginal")
def __init__(
self,
name: str,
parents: Optional[List[str]] = None,
cpt: Optional[Any] = None,
classifier: Optional[Callable[[Dict[str, Any]], Any]] = None,
):
self.name = name
self.parents = list(parents or [])
self.cpt = cpt
self.classifier = classifier
# Precompute: for no-parent nodes, cpt is the marginal list
self._marginal = cpt if not self.parents and classifier is None else None
def sample(self, context: Dict[str, Any], rng: random.Random) -> Any:
# Deterministic nodes don't sample
if self.classifier is not None:
return self.classifier(context)
if not self.parents:
# Marginal root
return _weighted_pick(self._marginal, rng)
# Conditional node: build the key from parent values
key = _parent_key(self.parents, context)
if key not in self.cpt:
# Fallback: concatenate all parents' tables (uniform over union)
# Keeps sampler from crashing if data doesn't cover some combo.
pool = []
for v in self.cpt.values():
pool.extend(v)
if not pool:
raise ValueError(
f"Node {self.name!r}: no CPT entries for {self.parents}={key}"
)
return _weighted_pick(pool, rng)
return _weighted_pick(self.cpt[key], rng)
class Network:
"""Collection of nodes with topological sampling."""
def __init__(self, nodes: List[Node]):
self.nodes = _topsort(nodes)
self.by_name = {n.name: n for n in self.nodes}
def sample(
self,
rng: random.Random,
evidence: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""Sample the network. ``evidence`` fixes named nodes BEFORE their children
sample, so the children RE-CONDITION on the fixed value (not relabel after).
Used to pin ``gpu_class`` to the validated WebGL persona's class so the whole
bundle (cores/screen/fonts) stays coherent with the GPU we expose. Earlier
nodes still sample (RNG stream preserved per-seed determinism)."""
evidence = evidence or {}
context: Dict[str, Any] = {}
for node in self.nodes:
if node.name in evidence:
context[node.name] = evidence[node.name]
else:
context[node.name] = node.sample(context, rng)
return context
# ── Helpers ─────────────────────────────────────────────────────────────
def _weighted_pick(table: List[Dict[str, Any]], rng: random.Random) -> Any:
"""`table` is a list of {value, prob} dicts. Returns one value."""
values = [e["value"] for e in table]
probs = [float(e["prob"]) for e in table]
if not values:
raise ValueError("Empty CPT entry")
total = sum(probs)
if total <= 0:
return rng.choice(values)
# Normalize to be safe (CPTs can be unnormalized)
probs = [p / total for p in probs]
return rng.choices(values, weights=probs, k=1)[0]
def _parent_key(parents: List[str], context: Dict[str, Any]) -> str:
"""Build a JSON-stable key from parent values in declared order."""
if len(parents) == 1:
v = context[parents[0]]
return v if isinstance(v, str) else json.dumps(v, sort_keys=True)
return json.dumps([context[p] for p in parents], sort_keys=True)
def _topsort(nodes: List[Node]) -> List[Node]:
"""Topological sort by parent-before-child."""
by_name = {n.name: n for n in nodes}
visited: set = set()
order: List[Node] = []
def visit(n: Node, path: set):
if n.name in visited:
return
if n.name in path:
raise ValueError(f"Cycle at {n.name}")
path.add(n.name)
for p in n.parents:
if p not in by_name:
raise ValueError(f"Node {n.name} has unknown parent {p}")
visit(by_name[p], path)
path.discard(n.name)
visited.add(n.name)
order.append(n)
for n in nodes:
visit(n, set())
return order

View file

@ -1,414 +0,0 @@
# -*- coding: utf-8 -*-
"""stealth_forge — Bayesian fingerprint generator for Firefox 150 Windows.
Everything the Firefox build exposes to JS (screen, hardwareConcurrency,
WebGL, audio, MSAA, theme, media codecs) is sampled from a Bayesian network
with coherent cross-field dependencies. Identity (userAgent, platform,
oscpu, webdriver=false, maxTouchPoints=0) is locked by the compiled build.
Graph:
gpu (root, 474 real Windows ANGLE renderers)
> gpu_class (deterministic classifier, 6 classes)
> hw_concurrency (CPT per class)
> screen (w/h/dpr/av) (CPT per class)
> msaa_samples (CPT per class)
audio (root, joint rate+latency+channels marginal)
dark_theme (marginal)
av1_enabled (marginal)
webm_encoder_enabled (marginal)
font_exclude deterministic hash of stealth_seed (seed-derived)
CPTs live in `data/*.json` (easy to tune without code changes).
Sampling is deterministic per stealth_seed via a private random.Random.
"""
import json
import os
import re
from typing import Any, Dict, Optional
from ._network import Network, Node
_HERE = os.path.dirname(os.path.abspath(__file__))
def _load(filename: str) -> Any:
with open(os.path.join(_HERE, "data", filename), "r", encoding="utf-8") as f:
return json.load(f)
# ═══════════════════════════════════════════════════════════════════════
# LOCKED IDENTITY (compiled into our Firefox 150 build — never varies)
# ═══════════════════════════════════════════════════════════════════════
_LOCKED: Dict[str, Any] = {
"user_agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:150.0) "
"Gecko/20100101 Firefox/150.0.1"
),
"platform": "Win32",
"oscpu": "Windows NT 10.0; Win64; x64",
"app_code_name": "Mozilla",
"app_version": "5.0 (Windows)",
"product_sub": "20100101",
"webdriver": False,
"max_touch_points": 0,
}
# ═══════════════════════════════════════════════════════════════════════
# DATA
# ═══════════════════════════════════════════════════════════════════════
_GPU_POOL = _load("webgl_renderer_pool.json")["entries"]
# hwc/screen/storage now keyed on (gpu_class, intra_tier) for triangulation
_CPT_HWC = _load("cpt_hwc_given_class_tier.json")["table"]
_CPT_SCREEN = _load("cpt_screen_given_class_tier.json")["table"]
_CPT_STORAGE = _load("cpt_storage_given_class_tier.json")["table"]
# Hidden tier variable that makes hwc/screen/storage jointly coherent
_CPT_INTRA_TIER = _load("cpt_intra_tier_given_class.json")["table"]
# MSAA depends on (gpu_class, screen_tier) — 4K gaming → MSAA=0, 1080p+GPU → MSAA=4
_CPT_MSAA = _load("cpt_msaa_given_class_screen.json")["table"]
# Codec unchanged
_CPT_CODEC = _load("cpt_codec_given_class.json")["table"]
# Audio now conditional on gpu_class (workstation → pro audio, old → 44.1kHz onboard)
_CPT_AUDIO = _load("cpt_audio_given_class.json")["table"]
_INDEP = _load("priors_independent.json")
_FONT_POOL = _load("font_pool.json")
# Each entry is a dict {"name": "<lowercase family>", "factor": float}.
# - name: the font family advertised to the page.
# - factor: per-family width scale used by the consumer to make the family
# detectable by width-diff probes.
# Core = always-included; Optional = sampled with P(font | gpu_class).
_FONT_CORE: list = _FONT_POOL["core"]
_FONT_OPTIONAL: list = _FONT_POOL["optional"]
_CPT_FONTS_OPT = _load("cpt_fonts_optional_given_class.json")["table"]
# Browsing-history pool + CPT (per-class probabilities for visited sites).
# Drives _recaptcha_seed's cookie pre-seed: each persona ends up with a
# coherent list of ~15-30 visited sites whose categories correlate with
# gpu_class (workstation → dev-heavy, integrated_old → shop+news-heavy).
_BROWSING_POOL: list = _load("browsing_pool.json")["entries"]
_CPT_BROWSING = _load("cpt_browsing_given_class.json")["table"]
# ═══════════════════════════════════════════════════════════════════════
# GPU CLASSIFIER (deterministic function of gpu → gpu_class)
# ═══════════════════════════════════════════════════════════════════════
_GPU_CLASSES = (
"integrated_old", "integrated_modern", "low_end",
"mid_range", "high_end", "workstation",
)
def classify_gpu(gpu_value: Dict[str, str]) -> str:
"""Deterministic: maps (renderer, vendor) dict to one of 6 classes.
See data/cpt_*.json each CPT table has an entry for every class.
"""
r = gpu_value.get("renderer", "")
if re.search(r"Intel.*HD Graphics (3000|4000|2500)", r):
return "integrated_old"
# Discrete Intel Arc DESKTOP/dGPU cards (A-series / B-series, e.g. A750,
# A770, B580) are discrete GPUs (~RTX 3060 tier for A7xx), NOT the
# integrated "Arc 130T/140T/Graphics" iGPUs in Core Ultra chips. Route the
# discrete SKUs to a coherent discrete-GPU class so the conditioned bundle
# (cores, screen, storage) matches a real discrete-GPU machine; A3xx are
# entry discrete -> low_end, A5xx/A7xx/Bxxx -> mid_range. Bare "Arc 1x0(T/V)"
# integrated names do NOT match and fall through to integrated_modern below.
m = re.search(r"Intel.*\bArc(?:\(TM\))?\s+([AB])(\d)\d\d\b", r)
if m:
return "low_end" if m.group(2) == "3" else "mid_range"
if re.search(
r"Intel.*(HD Graphics (4[56]|5\d\d|6\d\d)|UHD Graphics|Graphics Family|Iris|Arc)",
r,
):
return "integrated_modern"
if re.search(
r"AMD.*(Radeon(\(TM\))? (Graphics|6\d\dM|7\d\dM|8\d\dM)|Vega [0-9]|"
r"Renoir|Rembrandt|TM Graphics)",
r, re.IGNORECASE,
):
return "integrated_modern"
# NVIDIA: Firefox SanitizeRenderer.cpp collapses every GeForce into one of
# 3 vintage buckets (8800 GTX / GTX 480 / GTX 980). The renderer string
# exposed to JS is therefore vintage; pairing it with modern cores/screen
# creates an internal mismatch that FP Pro's tampering_ml flags. We pick
# `low_end` for all 3 buckets so cores stay 4-12 and screen 1080-1440p,
# consistent with what a real user with each of those (vintage) cards
# would have. Workstation overrides keep their high-tier classification.
if re.search(
r"(GeForce (8\d\d\d?|9\d\d\d?|GTX 980|GTX 480|GT 1030|GT 710|GT 730|"
r"GT 220|GT 240|210|310)|Quadro K\d|Radeon HD [1234]\d\d\d)", r,
):
return "low_end"
# NVIDIA discrete (any other GeForce — should be rare after the pool was
# collapsed to the 3 sanitize buckets, but kept as a safety net).
m = re.search(r"GeForce\s+(?:GTX\s+|RTX\s+)?(\d{3,4})", r)
if m:
if "Quadro" in r or "Workstation" in r:
return "workstation"
# Anything that survives the sanitize collapse stays low_end to avoid
# the modern-cores/vintage-renderer pairing.
return "low_end"
# AMD discrete
m = re.search(r"Radeon[^0-9]*(\d{3,4})", r)
if m:
n = int(m.group(1))
if "FirePro" in r or "Radeon Pro" in r:
return "workstation"
if n >= 5700:
return "high_end"
if 5500 <= n <= 5600 or 580 <= n <= 590:
return "mid_range"
return "low_end"
# Fallback
return "mid_range"
# ═══════════════════════════════════════════════════════════════════════
# NETWORK CONSTRUCTION
# ═══════════════════════════════════════════════════════════════════════
# Build once at import — the network is stateless, only the RNG varies.
def _gpu_marginal():
"""Build marginal distribution over GPU pool (uniform for now)."""
n = len(_GPU_POOL)
p = 1.0 / n
return [{"value": g, "prob": p} for g in _GPU_POOL]
def _cpt_from_table(table: Dict[str, Any]) -> Dict[str, list]:
"""CPT for conditional nodes: `{class_name: [{value, prob}, ...]}`."""
return dict(table)
def _screen_tier(ctx):
"""Classify screen width into tier for (gpu_class, screen_tier) CPTs."""
s = ctx.get("screen", {}) or {}
w = int(s.get("w", 1920))
h = int(s.get("h", 1080))
# Ultrawide: aspect ratio > 2.1 (e.g. 3440x1440, 5120x1440)
if h > 0 and (w / h) > 2.1:
return "ultrawide"
if w <= 1920:
return "1080p"
if w <= 2560:
return "1440p"
if w <= 3840:
return "2160p"
return "ultrawide"
_NETWORK = Network([
Node("gpu", parents=[], cpt=_gpu_marginal()),
Node("gpu_class", parents=["gpu"], classifier=lambda ctx: classify_gpu(ctx["gpu"])),
# Hidden variable: within a gpu_class, user's OTHER components (RAM, SSD,
# cores, screen) correlate — a 'premium' mid_range user has more cores,
# larger SSD, higher-res screen than a 'budget' mid_range user. Without
# this, hwc/screen/storage would be independent given gpu_class (noisy).
Node("intra_tier", parents=["gpu_class"], cpt=_cpt_from_table(_CPT_INTRA_TIER)),
# hwc/screen/storage now jointly coherent via (gpu_class, intra_tier).
Node("hw_concurrency", parents=["gpu_class", "intra_tier"],
cpt=_cpt_from_table(_CPT_HWC)),
Node("screen", parents=["gpu_class", "intra_tier"],
cpt=_cpt_from_table(_CPT_SCREEN)),
# Derive screen_tier from screen for msaa parent lookup.
Node("screen_tier", parents=["screen"], classifier=_screen_tier),
# MSAA: realistic combo (4K + high_end GPU → MSAA=0 due to perf cost;
# 1080p + high_end → MSAA=4 common; 1080p + integrated → MSAA=0).
Node("msaa_samples", parents=["gpu_class", "screen_tier"],
cpt=_cpt_from_table(_CPT_MSAA)),
# Joint codec distribution (gpu_class only).
Node("codec", parents=["gpu_class"], cpt=_cpt_from_table(_CPT_CODEC)),
# Storage quota: coherent within gpu_class × intra_tier (premium workstation
# user → 2-3TB SSD; budget workstation user → 512GB; budget integrated_old
# → 128GB).
Node("storage_quota_mb", parents=["gpu_class", "intra_tier"],
cpt=_cpt_from_table(_CPT_STORAGE)),
# Audio: pro users (workstation) → 48/96kHz 6-8ch; old onboard → 44.1kHz
# 2ch high latency. Workstation GPU + 44.1kHz mono was previously
# implausible; now blocked by the CPT.
Node("audio", parents=["gpu_class"], cpt=_cpt_from_table(_CPT_AUDIO)),
Node("dark_theme", parents=[], cpt=_INDEP["dark_theme"]["table"]),
])
# ═══════════════════════════════════════════════════════════════════════
# FONT WHITELIST (Bayesian: core sampled_optional | gpu_class)
# ═══════════════════════════════════════════════════════════════════════
# Semantic flip: previously exclude-list (block N probed fonts per seed).
# Now whitelist (browser sees ONLY these fonts, everything else hidden).
# Core (~112): always included — fresh Win11 + Office 2021 English.
# Optional (~40): sampled per-session with P(present | gpu_class). Gives
# small realistic variance (~3-8 optional fonts differ per session) while
# keeping the profile strongly centered on 'typical Windows user'.
def derive_font_prefs(gpu_class: str, rng) -> Dict[str, str]:
"""Build COHERENT whitelist + metrics strings for the session.
Sampling:
- Core fonts always included.
- Optional fonts sampled with P(font | gpu_class) from the CPT table.
Returns:
{
"whitelist": "arial,calibri,marlett,...",
"metrics": "arial|0.978,calibri|0.934,marlett|0.855,..."
}
The whitelist is the list of font families to advertise. The metrics
string encodes per-family width scale factors that the consumer can
use to make each family detectable by width-diff font probes.
Each entry in font_pool.json carries its own {name, factor} pair so the
two pref strings are GUARANTEED coherent no chance of a fabricated
font with factor 1.0 (undetectable) or a metrics entry for a font not
in the whitelist (useless).
Markers & add-new-font: simply add an entry to font_pool.json:core (with
a factor at least 4% away from 1.0) no special-case code needed.
"""
cpt = _CPT_FONTS_OPT.get(gpu_class)
if cpt is None:
cpt = _CPT_FONTS_OPT["integrated_modern"]
included: list = list(_FONT_CORE) # always present
for entry in _FONT_OPTIONAL:
name = entry["name"]
p = cpt.get(name, 0.7) # default 0.7 if CPT has no row for this font
if rng.random() < p:
included.append(entry)
# Deterministic ordering: sort by name
included.sort(key=lambda e: e["name"])
whitelist = ",".join(e["name"] for e in included)
metrics = ",".join(
f'{e["name"]}|{e["factor"]:.3f}' for e in included
)
return {"whitelist": whitelist, "metrics": metrics}
# Back-compat shim: legacy callers still import derive_font_whitelist.
def derive_font_whitelist(gpu_class: str, rng) -> str:
return derive_font_prefs(gpu_class, rng)["whitelist"]
# ═══════════════════════════════════════════════════════════════════════
# BROWSING HISTORY (Bayesian: per-site P(visited|gpu_class))
# ═══════════════════════════════════════════════════════════════════════
def derive_browsing_history(gpu_class: str, rng) -> list:
"""Sample which sites this persona has visited recently.
Each site in the pool has a per-class probability (CPT). We sample
independently per-site, producing a list of dicts:
[{"name": "github.com", "category": "dev", "cookie_profile": "ga_cf"}, ...]
Sum of CPT probabilities per class is tuned to land ~15-30 visited sites
on average an established-user signature. Sorted by name for stable
output across runs of the same seed.
"""
cpt = _CPT_BROWSING.get(gpu_class)
if cpt is None:
cpt = _CPT_BROWSING["mid_range"]
visited: list = []
for entry in _BROWSING_POOL:
name = entry["name"]
p = cpt.get(name, 0.3) # default 0.3 for missing CPT row
if rng.random() < p:
visited.append(dict(entry)) # copy to avoid mutating pool
visited.sort(key=lambda e: e["name"])
return visited
# ═══════════════════════════════════════════════════════════════════════
# PUBLIC API: Forge
# ═══════════════════════════════════════════════════════════════════════
import random
class Forge:
"""Fingerprint forge — single seed → coherent bundle."""
def __init__(self, seed: int):
self.seed = int(seed)
self._rng = random.Random(self.seed)
def sample(self, fixed_gpu_class: Optional[str] = None) -> Dict[str, Any]:
# fixed_gpu_class pins gpu_class so the WHOLE bundle (cores/screen/fonts) is
# drawn coherently for the WebGL persona's class we expose on Windows/mac.
# The default (no fix) path calls _NETWORK.sample(rng) with one arg so existing
# monkeypatches/tests keep working.
if fixed_gpu_class:
bundle = _NETWORK.sample(self._rng, evidence={"gpu_class": fixed_gpu_class})
else:
bundle = _NETWORK.sample(self._rng)
gpu = bundle["gpu"]
screen = bundle["screen"]
audio = bundle["audio"]
codec = bundle["codec"]
return {
# Seed tracking
"stealth_seed": self.seed,
# Locked identity
**_LOCKED,
# GPU (coherent pair from 474 pool)
"webgl_renderer": gpu["renderer"],
"webgl_vendor": gpu["vendor"],
"gpu_class": bundle["gpu_class"],
# Hidden-variable debug metadata (not a Firefox pref, just for
# analysis / test result correlation tracking)
"intra_tier": bundle["intra_tier"],
"screen_tier": bundle["screen_tier"],
# Screen (coherent with GPU class)
"screen_w": int(screen["w"]),
"screen_h": int(screen["h"]),
"screen_avail_w": int(screen.get("aw", screen["w"])),
"screen_avail_h": int(screen.get("ah", screen["h"] - 40)),
"dpr": float(screen["dpr"]),
# Hardware (coherent with GPU class)
"hw_concurrency": int(bundle["hw_concurrency"]),
# WebGL MSAA (coherent with GPU class)
"msaa_samples": int(bundle["msaa_samples"]),
# Audio (independent joint)
"audio_sample_rate": int(audio["rate"]),
"audio_output_latency_ms": int(audio["latency"]),
"audio_max_channel_count": int(audio["channels"]),
# Codec prefs (joint, coherent with GPU class). All 5 are
# JS-visible: av1/webm_encoder via canPlayType/MediaRecorder,
# mediasource_* via MediaSource.isTypeSupported, webspeech_synth
# via 'speechSynthesis' in window (CreepJS voices probe).
"av1_enabled": bool(codec["av1_enabled"]),
"webm_encoder_enabled": bool(codec["webm_encoder_enabled"]),
"mediasource_webm": bool(codec["mediasource_webm"]),
"mediasource_mp4": bool(codec["mediasource_mp4"]),
"webspeech_synth": bool(codec["webspeech_synth"]),
# Storage quota MB (coherent with GPU class — workstation larger SSDs).
"storage_quota_mb": int(bundle["storage_quota_mb"]),
# Independent marginals
"dark_theme": int(bundle["dark_theme"]),
# Bayesian font prefs (coherent pair: whitelist + per-family
# width scale metrics, both sampled from the same font_pool.json
# and conditioned on gpu_class).
**{
f"font_{k}": v
for k, v in derive_font_prefs(
bundle["gpu_class"], self._rng
).items()
},
# Bayesian browsing history (per-class P(visited|gpu_class)).
# Consumed by _recaptcha_seed.py to seed coherent cookie history
# when invisible_playwright is launched with prep_recaptcha=True.
"browsing_history": derive_browsing_history(
bundle["gpu_class"], self._rng
),
}
def sample(seed: int, fixed_gpu_class: Optional[str] = None) -> Dict[str, Any]:
"""Convenience: `Forge(seed).sample(fixed_gpu_class)`."""
return Forge(seed).sample(fixed_gpu_class)

View file

@ -1,64 +0,0 @@
{
"_comment": [
"Pool of everyday websites used by the browsing_history node.",
"Each entry: { name, category, cookie_profile }.",
"- name: bare domain (no scheme, no leading dot).",
"- category: dev / shop / news / reference / media / community / misc.",
"- cookie_profile: short tag pointing to a cookie-template recipe used by",
" _recaptcha_seed.py to generate concrete cookies (so heavy-analytics sites",
" get _ga+_gid+OneTrust, simple sites get just _ga, dev tools get GH-style).",
"Add new entries here + add per-class probabilities in cpt_browsing_given_class.json."
],
"entries": [
{"name": "youtube.com", "category": "media", "cookie_profile": "ga_only"},
{"name": "wikipedia.org", "category": "reference", "cookie_profile": "minimal"},
{"name": "mozilla.org", "category": "reference", "cookie_profile": "ga_consent"},
{"name": "w3schools.com", "category": "dev", "cookie_profile": "ga_consent_clarity"},
{"name": "mdn.io", "category": "dev", "cookie_profile": "minimal"},
{"name": "duckduckgo.com", "category": "reference", "cookie_profile": "minimal"},
{"name": "github.com", "category": "dev", "cookie_profile": "ga_cf"},
{"name": "stackoverflow.com", "category": "dev", "cookie_profile": "ga_consent_clarity"},
{"name": "npmjs.com", "category": "dev", "cookie_profile": "ga_consent"},
{"name": "gitlab.com", "category": "dev", "cookie_profile": "ga_cf"},
{"name": "pypi.org", "category": "dev", "cookie_profile": "minimal"},
{"name": "docs.python.org", "category": "dev", "cookie_profile": "minimal"},
{"name": "rust-lang.org", "category": "dev", "cookie_profile": "ga_consent"},
{"name": "go.dev", "category": "dev", "cookie_profile": "ga_consent"},
{"name": "amazon.com", "category": "shop", "cookie_profile": "ga_consent_clarity"},
{"name": "ebay.com", "category": "shop", "cookie_profile": "ga_consent"},
{"name": "etsy.com", "category": "shop", "cookie_profile": "ga_consent_clarity"},
{"name": "bestbuy.com", "category": "shop", "cookie_profile": "ga_consent_clarity"},
{"name": "target.com", "category": "shop", "cookie_profile": "ga_consent_clarity"},
{"name": "nytimes.com", "category": "news", "cookie_profile": "ga_consent_clarity"},
{"name": "cnn.com", "category": "news", "cookie_profile": "ga_consent"},
{"name": "bbc.com", "category": "news", "cookie_profile": "ga_consent"},
{"name": "theguardian.com", "category": "news", "cookie_profile": "ga_consent_clarity"},
{"name": "reuters.com", "category": "news", "cookie_profile": "ga_consent"},
{"name": "apnews.com", "category": "news", "cookie_profile": "ga_consent"},
{"name": "washingtonpost.com", "category": "news", "cookie_profile": "ga_consent"},
{"name": "techcrunch.com", "category": "news", "cookie_profile": "ga_consent_clarity"},
{"name": "theverge.com", "category": "news", "cookie_profile": "ga_consent"},
{"name": "arstechnica.com", "category": "news", "cookie_profile": "ga_consent"},
{"name": "wired.com", "category": "news", "cookie_profile": "ga_consent_clarity"},
{"name": "engadget.com", "category": "news", "cookie_profile": "ga_consent"},
{"name": "9to5mac.com", "category": "news", "cookie_profile": "ga_consent"},
{"name": "medium.com", "category": "community", "cookie_profile": "ga_consent"},
{"name": "dev.to", "category": "community", "cookie_profile": "ga_consent"},
{"name": "reddit.com", "category": "community", "cookie_profile": "ga_cf"},
{"name": "news.ycombinator.com", "category": "community", "cookie_profile": "minimal"},
{"name": "quora.com", "category": "community", "cookie_profile": "ga_consent_clarity"},
{"name": "stackexchange.com", "category": "community", "cookie_profile": "ga_consent_clarity"},
{"name": "imdb.com", "category": "media", "cookie_profile": "ga_consent_clarity"},
{"name": "rottentomatoes.com", "category": "media", "cookie_profile": "ga_consent"},
{"name": "metacritic.com", "category": "media", "cookie_profile": "ga_consent"},
{"name": "allrecipes.com", "category": "misc", "cookie_profile": "ga_consent_clarity"},
{"name": "epicurious.com", "category": "misc", "cookie_profile": "ga_consent"},
{"name": "tripadvisor.com", "category": "misc", "cookie_profile": "ga_consent_clarity"},
{"name": "weather.com", "category": "reference", "cookie_profile": "ga_consent"},
{"name": "timeanddate.com", "category": "reference", "cookie_profile": "ga_consent"},
{"name": "thesaurus.com", "category": "reference", "cookie_profile": "ga_consent_clarity"},
{"name": "kayak.com", "category": "shop", "cookie_profile": "ga_consent_clarity"},
{"name": "booking.com", "category": "shop", "cookie_profile": "ga_consent_clarity"},
{"name": "airbnb.com", "category": "shop", "cookie_profile": "ga_consent"}
]
}

View file

@ -1,217 +0,0 @@
{
"_meta": "audio (rate/latency/channels) given gpu_class. NOTE 2026-06-14: maxChannelCount reflects the OS DEFAULT OUTPUT DEVICE (stereo for the vast majority of users), NOT the GPU — so channels=2 dominates every class (~78-92%) with only a small 6/8 surround tail. The previous tables emitted 45-100% surround on mid/high/workstation, which is unrealistic and lifted FP Pro tampering_ml (surround on a typical consumer profile reads as a coherence anomaly). Rate/latency tuples are unchanged.",
"table": {
"integrated_old": [
{
"value": {
"rate": 44100,
"latency": 50,
"channels": 2
},
"prob": 0.7
},
{
"value": {
"rate": 48000,
"latency": 50,
"channels": 2
},
"prob": 0.3
}
],
"integrated_modern": [
{
"value": {
"rate": 48000,
"latency": 30,
"channels": 2
},
"prob": 0.62
},
{
"value": {
"rate": 44100,
"latency": 40,
"channels": 2
},
"prob": 0.3
},
{
"value": {
"rate": 48000,
"latency": 25,
"channels": 6
},
"prob": 0.08
}
],
"low_end": [
{
"value": {
"rate": 48000,
"latency": 40,
"channels": 2
},
"prob": 0.6
},
{
"value": {
"rate": 44100,
"latency": 50,
"channels": 2
},
"prob": 0.32
},
{
"value": {
"rate": 48000,
"latency": 30,
"channels": 6
},
"prob": 0.08
}
],
"mid_range": [
{
"value": {
"rate": 48000,
"latency": 25,
"channels": 2
},
"prob": 0.5
},
{
"value": {
"rate": 48000,
"latency": 20,
"channels": 2
},
"prob": 0.3
},
{
"value": {
"rate": 44100,
"latency": 30,
"channels": 2
},
"prob": 0.12
},
{
"value": {
"rate": 48000,
"latency": 20,
"channels": 6
},
"prob": 0.06
},
{
"value": {
"rate": 48000,
"latency": 20,
"channels": 8
},
"prob": 0.02
}
],
"high_end": [
{
"value": {
"rate": 48000,
"latency": 15,
"channels": 2
},
"prob": 0.6
},
{
"value": {
"rate": 96000,
"latency": 15,
"channels": 2
},
"prob": 0.18
},
{
"value": {
"rate": 48000,
"latency": 15,
"channels": 6
},
"prob": 0.1
},
{
"value": {
"rate": 48000,
"latency": 15,
"channels": 8
},
"prob": 0.05
},
{
"value": {
"rate": 96000,
"latency": 15,
"channels": 6
},
"prob": 0.05
},
{
"value": {
"rate": 96000,
"latency": 15,
"channels": 8
},
"prob": 0.02
}
],
"workstation": [
{
"value": {
"rate": 48000,
"latency": 10,
"channels": 2
},
"prob": 0.45
},
{
"value": {
"rate": 96000,
"latency": 10,
"channels": 2
},
"prob": 0.2
},
{
"value": {
"rate": 48000,
"latency": 10,
"channels": 8
},
"prob": 0.12
},
{
"value": {
"rate": 96000,
"latency": 10,
"channels": 8
},
"prob": 0.1
},
{
"value": {
"rate": 96000,
"latency": 10,
"channels": 6
},
"prob": 0.08
},
{
"value": {
"rate": 192000,
"latency": 10,
"channels": 8
},
"prob": 0.05
}
]
}
}

View file

@ -1,138 +0,0 @@
{
"_comment": [
"Per-class probability that a persona of a given gpu_class has visited each",
"site in the pool. Used by the browsing_history node to derive a coherent",
"visited-domain list per persona.",
"",
"Probabilities are tuned so each class samples ~15-30 sites on average",
"(sum across all 50 entries falls in that range), giving an established-user",
"look. Categories are biased by class:",
" - workstation/high_end: higher P(dev) + high P(news/media)",
" - mid_range: balanced",
" - low_end/integrated_*: lower P(dev), higher P(shop/news/reference)",
"",
"Missing class falls back to mid_range via Node CPT pool fallback."
],
"table": {
"workstation": {
"youtube.com": 0.80, "wikipedia.org": 0.85, "mozilla.org": 0.70,
"w3schools.com": 0.40, "mdn.io": 0.55, "duckduckgo.com": 0.45,
"github.com": 0.95, "stackoverflow.com": 0.90, "npmjs.com": 0.65,
"gitlab.com": 0.50, "pypi.org": 0.55, "docs.python.org": 0.60,
"rust-lang.org": 0.35, "go.dev": 0.30,
"amazon.com": 0.70, "ebay.com": 0.25, "etsy.com": 0.15,
"bestbuy.com": 0.45, "target.com": 0.30,
"nytimes.com": 0.55, "cnn.com": 0.40, "bbc.com": 0.55,
"theguardian.com": 0.45, "reuters.com": 0.40, "apnews.com": 0.30,
"washingtonpost.com": 0.40,
"techcrunch.com": 0.65, "theverge.com": 0.60, "arstechnica.com": 0.65,
"wired.com": 0.50, "engadget.com": 0.35, "9to5mac.com": 0.30,
"medium.com": 0.55, "dev.to": 0.40, "reddit.com": 0.70,
"news.ycombinator.com": 0.65, "quora.com": 0.20, "stackexchange.com": 0.60,
"imdb.com": 0.45, "rottentomatoes.com": 0.25, "metacritic.com": 0.20,
"allrecipes.com": 0.20, "epicurious.com": 0.15, "tripadvisor.com": 0.30,
"weather.com": 0.55, "timeanddate.com": 0.30, "thesaurus.com": 0.25,
"kayak.com": 0.30, "booking.com": 0.35, "airbnb.com": 0.30
},
"high_end": {
"youtube.com": 0.85, "wikipedia.org": 0.80, "mozilla.org": 0.60,
"w3schools.com": 0.45, "mdn.io": 0.45, "duckduckgo.com": 0.40,
"github.com": 0.85, "stackoverflow.com": 0.80, "npmjs.com": 0.50,
"gitlab.com": 0.40, "pypi.org": 0.45, "docs.python.org": 0.50,
"rust-lang.org": 0.30, "go.dev": 0.25,
"amazon.com": 0.75, "ebay.com": 0.30, "etsy.com": 0.20,
"bestbuy.com": 0.50, "target.com": 0.35,
"nytimes.com": 0.50, "cnn.com": 0.50, "bbc.com": 0.50,
"theguardian.com": 0.40, "reuters.com": 0.35, "apnews.com": 0.30,
"washingtonpost.com": 0.35,
"techcrunch.com": 0.60, "theverge.com": 0.65, "arstechnica.com": 0.60,
"wired.com": 0.50, "engadget.com": 0.40, "9to5mac.com": 0.35,
"medium.com": 0.50, "dev.to": 0.35, "reddit.com": 0.75,
"news.ycombinator.com": 0.55, "quora.com": 0.25, "stackexchange.com": 0.55,
"imdb.com": 0.55, "rottentomatoes.com": 0.35, "metacritic.com": 0.30,
"allrecipes.com": 0.25, "epicurious.com": 0.20, "tripadvisor.com": 0.30,
"weather.com": 0.55, "timeanddate.com": 0.30, "thesaurus.com": 0.25,
"kayak.com": 0.30, "booking.com": 0.40, "airbnb.com": 0.30
},
"mid_range": {
"youtube.com": 0.85, "wikipedia.org": 0.75, "mozilla.org": 0.45,
"w3schools.com": 0.40, "mdn.io": 0.30, "duckduckgo.com": 0.35,
"github.com": 0.55, "stackoverflow.com": 0.55, "npmjs.com": 0.30,
"gitlab.com": 0.25, "pypi.org": 0.25, "docs.python.org": 0.30,
"rust-lang.org": 0.15, "go.dev": 0.15,
"amazon.com": 0.80, "ebay.com": 0.40, "etsy.com": 0.30,
"bestbuy.com": 0.55, "target.com": 0.40,
"nytimes.com": 0.45, "cnn.com": 0.55, "bbc.com": 0.45,
"theguardian.com": 0.35, "reuters.com": 0.30, "apnews.com": 0.30,
"washingtonpost.com": 0.30,
"techcrunch.com": 0.45, "theverge.com": 0.50, "arstechnica.com": 0.40,
"wired.com": 0.45, "engadget.com": 0.35, "9to5mac.com": 0.30,
"medium.com": 0.45, "dev.to": 0.25, "reddit.com": 0.70,
"news.ycombinator.com": 0.30, "quora.com": 0.35, "stackexchange.com": 0.40,
"imdb.com": 0.60, "rottentomatoes.com": 0.40, "metacritic.com": 0.35,
"allrecipes.com": 0.35, "epicurious.com": 0.25, "tripadvisor.com": 0.40,
"weather.com": 0.60, "timeanddate.com": 0.25, "thesaurus.com": 0.30,
"kayak.com": 0.35, "booking.com": 0.45, "airbnb.com": 0.40
},
"low_end": {
"youtube.com": 0.85, "wikipedia.org": 0.70, "mozilla.org": 0.35,
"w3schools.com": 0.30, "mdn.io": 0.20, "duckduckgo.com": 0.30,
"github.com": 0.30, "stackoverflow.com": 0.30, "npmjs.com": 0.15,
"gitlab.com": 0.10, "pypi.org": 0.10, "docs.python.org": 0.15,
"rust-lang.org": 0.05, "go.dev": 0.05,
"amazon.com": 0.85, "ebay.com": 0.50, "etsy.com": 0.40,
"bestbuy.com": 0.55, "target.com": 0.45,
"nytimes.com": 0.40, "cnn.com": 0.60, "bbc.com": 0.40,
"theguardian.com": 0.30, "reuters.com": 0.25, "apnews.com": 0.30,
"washingtonpost.com": 0.25,
"techcrunch.com": 0.30, "theverge.com": 0.35, "arstechnica.com": 0.25,
"wired.com": 0.40, "engadget.com": 0.30, "9to5mac.com": 0.25,
"medium.com": 0.35, "dev.to": 0.15, "reddit.com": 0.65,
"news.ycombinator.com": 0.15, "quora.com": 0.45, "stackexchange.com": 0.25,
"imdb.com": 0.65, "rottentomatoes.com": 0.45, "metacritic.com": 0.35,
"allrecipes.com": 0.45, "epicurious.com": 0.30, "tripadvisor.com": 0.45,
"weather.com": 0.65, "timeanddate.com": 0.25, "thesaurus.com": 0.35,
"kayak.com": 0.35, "booking.com": 0.50, "airbnb.com": 0.40
},
"integrated_modern": {
"youtube.com": 0.85, "wikipedia.org": 0.70, "mozilla.org": 0.40,
"w3schools.com": 0.35, "mdn.io": 0.25, "duckduckgo.com": 0.35,
"github.com": 0.40, "stackoverflow.com": 0.40, "npmjs.com": 0.20,
"gitlab.com": 0.15, "pypi.org": 0.20, "docs.python.org": 0.20,
"rust-lang.org": 0.10, "go.dev": 0.10,
"amazon.com": 0.80, "ebay.com": 0.40, "etsy.com": 0.30,
"bestbuy.com": 0.50, "target.com": 0.40,
"nytimes.com": 0.40, "cnn.com": 0.55, "bbc.com": 0.45,
"theguardian.com": 0.35, "reuters.com": 0.30, "apnews.com": 0.30,
"washingtonpost.com": 0.30,
"techcrunch.com": 0.40, "theverge.com": 0.45, "arstechnica.com": 0.30,
"wired.com": 0.40, "engadget.com": 0.30, "9to5mac.com": 0.25,
"medium.com": 0.40, "dev.to": 0.20, "reddit.com": 0.65,
"news.ycombinator.com": 0.25, "quora.com": 0.40, "stackexchange.com": 0.35,
"imdb.com": 0.60, "rottentomatoes.com": 0.40, "metacritic.com": 0.30,
"allrecipes.com": 0.40, "epicurious.com": 0.25, "tripadvisor.com": 0.40,
"weather.com": 0.60, "timeanddate.com": 0.25, "thesaurus.com": 0.30,
"kayak.com": 0.35, "booking.com": 0.45, "airbnb.com": 0.40
},
"integrated_old": {
"youtube.com": 0.75, "wikipedia.org": 0.65, "mozilla.org": 0.30,
"w3schools.com": 0.20, "mdn.io": 0.10, "duckduckgo.com": 0.25,
"github.com": 0.15, "stackoverflow.com": 0.20, "npmjs.com": 0.05,
"gitlab.com": 0.05, "pypi.org": 0.05, "docs.python.org": 0.10,
"rust-lang.org": 0.02, "go.dev": 0.02,
"amazon.com": 0.85, "ebay.com": 0.55, "etsy.com": 0.45,
"bestbuy.com": 0.55, "target.com": 0.50,
"nytimes.com": 0.45, "cnn.com": 0.65, "bbc.com": 0.40,
"theguardian.com": 0.30, "reuters.com": 0.25, "apnews.com": 0.35,
"washingtonpost.com": 0.30,
"techcrunch.com": 0.20, "theverge.com": 0.25, "arstechnica.com": 0.15,
"wired.com": 0.30, "engadget.com": 0.20, "9to5mac.com": 0.20,
"medium.com": 0.30, "dev.to": 0.05, "reddit.com": 0.55,
"news.ycombinator.com": 0.05, "quora.com": 0.55, "stackexchange.com": 0.15,
"imdb.com": 0.70, "rottentomatoes.com": 0.50, "metacritic.com": 0.35,
"allrecipes.com": 0.55, "epicurious.com": 0.35, "tripadvisor.com": 0.50,
"weather.com": 0.70, "timeanddate.com": 0.30, "thesaurus.com": 0.40,
"kayak.com": 0.40, "booking.com": 0.55, "airbnb.com": 0.40
}
}
}

View file

@ -1,147 +0,0 @@
{
"_meta": "codec given gpu_class",
"table": {
"integrated_old": [
{
"value": {
"av1_enabled": false,
"webm_encoder_enabled": false,
"mediasource_webm": true,
"mediasource_mp4": true,
"webspeech_synth": true
},
"prob": 1.0
}
],
"integrated_modern": [
{
"value": {
"av1_enabled": true,
"webm_encoder_enabled": true,
"mediasource_webm": true,
"mediasource_mp4": true,
"webspeech_synth": true
},
"prob": 0.55
},
{
"value": {
"av1_enabled": false,
"webm_encoder_enabled": true,
"mediasource_webm": true,
"mediasource_mp4": true,
"webspeech_synth": true
},
"prob": 0.35
},
{
"value": {
"av1_enabled": true,
"webm_encoder_enabled": true,
"mediasource_webm": true,
"mediasource_mp4": true,
"webspeech_synth": false
},
"prob": 0.1
}
],
"low_end": [
{
"value": {
"av1_enabled": false,
"webm_encoder_enabled": true,
"mediasource_webm": true,
"mediasource_mp4": true,
"webspeech_synth": true
},
"prob": 0.85
},
{
"value": {
"av1_enabled": false,
"webm_encoder_enabled": true,
"mediasource_webm": true,
"mediasource_mp4": true,
"webspeech_synth": false
},
"prob": 0.15
}
],
"mid_range": [
{
"value": {
"av1_enabled": true,
"webm_encoder_enabled": true,
"mediasource_webm": true,
"mediasource_mp4": true,
"webspeech_synth": true
},
"prob": 0.55
},
{
"value": {
"av1_enabled": false,
"webm_encoder_enabled": true,
"mediasource_webm": true,
"mediasource_mp4": true,
"webspeech_synth": true
},
"prob": 0.35
},
{
"value": {
"av1_enabled": true,
"webm_encoder_enabled": true,
"mediasource_webm": true,
"mediasource_mp4": true,
"webspeech_synth": false
},
"prob": 0.1
}
],
"high_end": [
{
"value": {
"av1_enabled": true,
"webm_encoder_enabled": true,
"mediasource_webm": true,
"mediasource_mp4": true,
"webspeech_synth": true
},
"prob": 0.85
},
{
"value": {
"av1_enabled": true,
"webm_encoder_enabled": true,
"mediasource_webm": true,
"mediasource_mp4": true,
"webspeech_synth": false
},
"prob": 0.15
}
],
"workstation": [
{
"value": {
"av1_enabled": true,
"webm_encoder_enabled": true,
"mediasource_webm": true,
"mediasource_mp4": true,
"webspeech_synth": true
},
"prob": 0.7
},
{
"value": {
"av1_enabled": true,
"webm_encoder_enabled": true,
"mediasource_webm": true,
"mediasource_mp4": true,
"webspeech_synth": false
},
"prob": 0.3
}
]
}
}

View file

@ -1,295 +0,0 @@
{
"_meta": {
"name": "optional_font presence | gpu_class",
"parents": [
"gpu_class"
],
"child": "optional_fonts_subset",
"description": "Per-optional-font presence probabilities given gpu_class. Each optional font in font_pool.json sampled INDEPENDENTLY with P(present | gpu_class) given here. Integrated_old: fewer language packs (older/cheaper machines). Workstation: more regional/language packs (international users, enterprise deployments).",
"rationale": "Near-invariant by design: most optional fonts have baseline P ~ 0.60-0.85 across classes. Per-session variance is small (~3-6 fonts toggling on/off out of 40 optional). Result: fingerprint always looks like 'typical Windows 11 + Office', with small realistic per-user variance in regional support."
},
"table": {
"integrated_old": {
"aparajita": 0.45,
"calibri": 0.9,
"dengxian": 0.3,
"dfkai-sb": 0.25,
"dokchampa": 0.2,
"estrangelo edessa": 0.55,
"euphemia": 0.6,
"fangsong": 0.35,
"gadugi": 0.88,
"gautami": 0.55,
"helv": 0.7,
"iskoola pota": 0.5,
"javanese text": 0.85,
"kaiti": 0.3,
"kalinga": 0.5,
"kartika": 0.5,
"khmer ui": 0.35,
"kokila": 0.55,
"lao ui": 0.35,
"latha": 0.65,
"leelawadee ui": 0.87,
"mangal": 0.65,
"meiryo": 0.6,
"microsoft uighur": 0.4,
"ms pmincho": 0.65,
"ms reference sans serif": 0.55,
"ms reference specialty": 0.5,
"ms ui gothic": 0.82,
"myanmar text": 0.84,
"nyala": 0.55,
"plantagenet cherokee": 0.6,
"raavi": 0.55,
"segoe fluent icons": 0.5,
"segoe ui light": 0.75,
"shonar bangla": 0.55,
"shruti": 0.55,
"simkai": 0.25,
"small fonts": 0.75,
"traditional arabic": 0.55,
"tunga": 0.55,
"urdu typesetting": 0.45,
"utsaah": 0.55,
"vani": 0.55,
"vijaya": 0.55,
"yu mincho": 0.55
},
"integrated_modern": {
"aparajita": 0.65,
"calibri": 0.78,
"dengxian": 0.5,
"dfkai-sb": 0.4,
"dokchampa": 0.4,
"estrangelo edessa": 0.7,
"euphemia": 0.78,
"fangsong": 0.55,
"gadugi": 0.96,
"gautami": 0.75,
"helv": 0.6,
"iskoola pota": 0.7,
"javanese text": 0.94,
"kaiti": 0.45,
"kalinga": 0.7,
"kartika": 0.7,
"khmer ui": 0.55,
"kokila": 0.75,
"lao ui": 0.55,
"latha": 0.82,
"leelawadee ui": 0.95,
"mangal": 0.82,
"meiryo": 0.8,
"microsoft uighur": 0.55,
"ms pmincho": 0.85,
"ms reference sans serif": 0.72,
"ms reference specialty": 0.68,
"ms ui gothic": 0.75,
"myanmar text": 0.94,
"nyala": 0.72,
"plantagenet cherokee": 0.78,
"raavi": 0.72,
"segoe fluent icons": 0.92,
"segoe ui light": 0.72,
"shonar bangla": 0.72,
"shruti": 0.72,
"simkai": 0.4,
"small fonts": 0.65,
"traditional arabic": 0.72,
"tunga": 0.72,
"urdu typesetting": 0.65,
"utsaah": 0.72,
"vani": 0.72,
"vijaya": 0.72,
"yu mincho": 0.75
},
"low_end": {
"aparajita": 0.55,
"calibri": 0.94,
"dengxian": 0.4,
"dfkai-sb": 0.3,
"dokchampa": 0.3,
"estrangelo edessa": 0.62,
"euphemia": 0.68,
"fangsong": 0.45,
"gadugi": 0.93,
"gautami": 0.65,
"helv": 0.78,
"iskoola pota": 0.6,
"javanese text": 0.91,
"kaiti": 0.35,
"kalinga": 0.6,
"kartika": 0.6,
"khmer ui": 0.45,
"kokila": 0.65,
"lao ui": 0.45,
"latha": 0.72,
"leelawadee ui": 0.92,
"mangal": 0.72,
"meiryo": 0.7,
"microsoft uighur": 0.48,
"ms pmincho": 0.75,
"ms reference sans serif": 0.62,
"ms reference specialty": 0.58,
"ms ui gothic": 0.9,
"myanmar text": 0.9,
"nyala": 0.62,
"plantagenet cherokee": 0.68,
"raavi": 0.62,
"segoe fluent icons": 0.8,
"segoe ui light": 0.88,
"shonar bangla": 0.62,
"shruti": 0.62,
"simkai": 0.3,
"small fonts": 0.83,
"traditional arabic": 0.62,
"tunga": 0.62,
"urdu typesetting": 0.55,
"utsaah": 0.62,
"vani": 0.62,
"vijaya": 0.62,
"yu mincho": 0.65
},
"mid_range": {
"aparajita": 0.72,
"calibri": 0.98,
"dengxian": 0.6,
"dfkai-sb": 0.5,
"dokchampa": 0.5,
"estrangelo edessa": 0.78,
"euphemia": 0.82,
"fangsong": 0.65,
"gadugi": 0.97,
"gautami": 0.8,
"helv": 0.85,
"iskoola pota": 0.78,
"javanese text": 0.96,
"kaiti": 0.55,
"kalinga": 0.78,
"kartika": 0.78,
"khmer ui": 0.65,
"kokila": 0.8,
"lao ui": 0.65,
"latha": 0.85,
"leelawadee ui": 0.97,
"mangal": 0.85,
"meiryo": 0.85,
"microsoft uighur": 0.65,
"ms pmincho": 0.88,
"ms reference sans serif": 0.78,
"ms reference specialty": 0.75,
"ms ui gothic": 0.96,
"myanmar text": 0.96,
"nyala": 0.78,
"plantagenet cherokee": 0.82,
"raavi": 0.78,
"segoe fluent icons": 0.94,
"segoe ui light": 0.94,
"shonar bangla": 0.78,
"shruti": 0.78,
"simkai": 0.5,
"small fonts": 0.9,
"traditional arabic": 0.78,
"tunga": 0.78,
"urdu typesetting": 0.72,
"utsaah": 0.78,
"vani": 0.78,
"vijaya": 0.78,
"yu mincho": 0.8
},
"high_end": {
"aparajita": 0.8,
"calibri": 0.99,
"dengxian": 0.7,
"dfkai-sb": 0.6,
"dokchampa": 0.6,
"estrangelo edessa": 0.85,
"euphemia": 0.88,
"fangsong": 0.72,
"gadugi": 0.98,
"gautami": 0.85,
"helv": 0.88,
"iskoola pota": 0.82,
"javanese text": 0.97,
"kaiti": 0.65,
"kalinga": 0.82,
"kartika": 0.82,
"khmer ui": 0.72,
"kokila": 0.85,
"lao ui": 0.72,
"latha": 0.9,
"leelawadee ui": 0.98,
"mangal": 0.9,
"meiryo": 0.9,
"microsoft uighur": 0.72,
"ms pmincho": 0.92,
"ms reference sans serif": 0.85,
"ms reference specialty": 0.82,
"ms ui gothic": 0.98,
"myanmar text": 0.97,
"nyala": 0.85,
"plantagenet cherokee": 0.88,
"raavi": 0.85,
"segoe fluent icons": 0.97,
"segoe ui light": 0.96,
"shonar bangla": 0.85,
"shruti": 0.85,
"simkai": 0.6,
"small fonts": 0.92,
"traditional arabic": 0.85,
"tunga": 0.85,
"urdu typesetting": 0.8,
"utsaah": 0.85,
"vani": 0.85,
"vijaya": 0.85,
"yu mincho": 0.88
},
"workstation": {
"aparajita": 0.88,
"calibri": 0.99,
"dengxian": 0.8,
"dfkai-sb": 0.75,
"dokchampa": 0.72,
"estrangelo edessa": 0.9,
"euphemia": 0.92,
"fangsong": 0.82,
"gadugi": 0.99,
"gautami": 0.92,
"helv": 0.9,
"iskoola pota": 0.9,
"javanese text": 0.98,
"kaiti": 0.78,
"kalinga": 0.9,
"kartika": 0.9,
"khmer ui": 0.8,
"kokila": 0.92,
"lao ui": 0.8,
"latha": 0.95,
"leelawadee ui": 0.99,
"mangal": 0.95,
"meiryo": 0.95,
"microsoft uighur": 0.82,
"ms pmincho": 0.95,
"ms reference sans serif": 0.92,
"ms reference specialty": 0.9,
"ms ui gothic": 0.98,
"myanmar text": 0.98,
"nyala": 0.92,
"plantagenet cherokee": 0.92,
"raavi": 0.92,
"segoe fluent icons": 0.98,
"segoe ui light": 0.97,
"shonar bangla": 0.92,
"shruti": 0.92,
"simkai": 0.72,
"small fonts": 0.94,
"traditional arabic": 0.92,
"tunga": 0.92,
"urdu typesetting": 0.88,
"utsaah": 0.92,
"vani": 0.92,
"vijaya": 0.92,
"yu mincho": 0.92
}
}
}

View file

@ -1,50 +0,0 @@
{
"_meta": {
"name": "hw_concurrency | gpu_class",
"parents": ["gpu_class"],
"child": "hw_concurrency",
"source": "Curated from realistic CPU/GPU pairings (Steam HW Survey + DIY build norms + laptop SKU patterns)"
},
"table": {
"integrated_old": [
{"value": 2, "prob": 0.20},
{"value": 4, "prob": 0.60},
{"value": 8, "prob": 0.20}
],
"integrated_modern": [
{"value": 4, "prob": 0.20},
{"value": 6, "prob": 0.15},
{"value": 8, "prob": 0.35},
{"value": 12, "prob": 0.20},
{"value": 16, "prob": 0.10}
],
"low_end": [
{"value": 4, "prob": 0.25},
{"value": 6, "prob": 0.25},
{"value": 8, "prob": 0.35},
{"value": 12, "prob": 0.15}
],
"mid_range": [
{"value": 6, "prob": 0.15},
{"value": 8, "prob": 0.30},
{"value": 12, "prob": 0.30},
{"value": 16, "prob": 0.20},
{"value": 24, "prob": 0.05}
],
"high_end": [
{"value": 8, "prob": 0.10},
{"value": 12, "prob": 0.25},
{"value": 16, "prob": 0.35},
{"value": 20, "prob": 0.05},
{"value": 24, "prob": 0.20},
{"value": 32, "prob": 0.05}
],
"workstation": [
{"value": 8, "prob": 0.15},
{"value": 12, "prob": 0.20},
{"value": 16, "prob": 0.30},
{"value": 24, "prob": 0.20},
{"value": 32, "prob": 0.15}
]
}
}

View file

@ -1,337 +0,0 @@
{
"_meta": "hardware_concurrency given (gpu_class, intra_tier)",
"table": {
"[\"integrated_old\", \"budget\"]": [
{
"value": 2,
"prob": 0.65
},
{
"value": 4,
"prob": 0.3
},
{
"value": 8,
"prob": 0.05
}
],
"[\"integrated_old\", \"standard\"]": [
{
"value": 2,
"prob": 0.3
},
{
"value": 4,
"prob": 0.55
},
{
"value": 8,
"prob": 0.15
}
],
"[\"integrated_old\", \"premium\"]": [
{
"value": 4,
"prob": 0.65
},
{
"value": 8,
"prob": 0.35
}
],
"[\"integrated_modern\", \"budget\"]": [
{
"value": 6,
"prob": 0.45
},
{
"value": 8,
"prob": 0.4
},
{
"value": 12,
"prob": 0.15
}
],
"[\"integrated_modern\", \"standard\"]": [
{
"value": 6,
"prob": 0.2
},
{
"value": 8,
"prob": 0.3
},
{
"value": 10,
"prob": 0.2
},
{
"value": 12,
"prob": 0.2
},
{
"value": 16,
"prob": 0.1
}
],
"[\"integrated_modern\", \"premium\"]": [
{
"value": 8,
"prob": 0.2
},
{
"value": 10,
"prob": 0.2
},
{
"value": 12,
"prob": 0.3
},
{
"value": 14,
"prob": 0.15
},
{
"value": 16,
"prob": 0.15
}
],
"[\"low_end\", \"budget\"]": [
{
"value": 4,
"prob": 0.5
},
{
"value": 6,
"prob": 0.25
},
{
"value": 8,
"prob": 0.2
},
{
"value": 12,
"prob": 0.05
}
],
"[\"low_end\", \"standard\"]": [
{
"value": 4,
"prob": 0.1
},
{
"value": 6,
"prob": 0.35
},
{
"value": 8,
"prob": 0.3
},
{
"value": 12,
"prob": 0.18
},
{
"value": 16,
"prob": 0.07
}
],
"[\"low_end\", \"premium\"]": [
{
"value": 6,
"prob": 0.1
},
{
"value": 8,
"prob": 0.3
},
{
"value": 12,
"prob": 0.3
},
{
"value": 16,
"prob": 0.22
},
{
"value": 24,
"prob": 0.08
}
],
"[\"mid_range\", \"budget\"]": [
{
"value": 6,
"prob": 0.55
},
{
"value": 8,
"prob": 0.3
},
{
"value": 12,
"prob": 0.15
}
],
"[\"mid_range\", \"standard\"]": [
{
"value": 6,
"prob": 0.4
},
{
"value": 8,
"prob": 0.3
},
{
"value": 12,
"prob": 0.18
},
{
"value": 16,
"prob": 0.1
},
{
"value": 24,
"prob": 0.02
}
],
"[\"mid_range\", \"premium\"]": [
{
"value": 6,
"prob": 0.15
},
{
"value": 8,
"prob": 0.45
},
{
"value": 12,
"prob": 0.2
},
{
"value": 16,
"prob": 0.15
},
{
"value": 24,
"prob": 0.05
}
],
"[\"high_end\", \"budget\"]": [
{
"value": 6,
"prob": 0.1
},
{
"value": 8,
"prob": 0.55
},
{
"value": 12,
"prob": 0.2
},
{
"value": 16,
"prob": 0.15
}
],
"[\"high_end\", \"standard\"]": [
{
"value": 8,
"prob": 0.3
},
{
"value": 12,
"prob": 0.18
},
{
"value": 14,
"prob": 0.15
},
{
"value": 16,
"prob": 0.27
},
{
"value": 24,
"prob": 0.1
}
],
"[\"high_end\", \"premium\"]": [
{
"value": 12,
"prob": 0.1
},
{
"value": 14,
"prob": 0.15
},
{
"value": 16,
"prob": 0.3
},
{
"value": 24,
"prob": 0.35
},
{
"value": 32,
"prob": 0.1
}
],
"[\"workstation\", \"budget\"]": [
{
"value": 8,
"prob": 0.4
},
{
"value": 12,
"prob": 0.3
},
{
"value": 16,
"prob": 0.2
},
{
"value": 24,
"prob": 0.1
}
],
"[\"workstation\", \"standard\"]": [
{
"value": 12,
"prob": 0.1
},
{
"value": 16,
"prob": 0.4
},
{
"value": 24,
"prob": 0.3
},
{
"value": 32,
"prob": 0.2
}
],
"[\"workstation\", \"premium\"]": [
{
"value": 24,
"prob": 0.3
},
{
"value": 32,
"prob": 0.45
},
{
"value": 48,
"prob": 0.15
},
{
"value": 64,
"prob": 0.1
}
]
}
}

View file

@ -1,17 +0,0 @@
{
"_meta": {
"name": "intra_tier | gpu_class",
"parents": ["gpu_class"],
"child": "intra_tier",
"value_type": "str (budget|standard|premium)",
"rationale": "Hidden variable catching 'intra-class premium-ness'. Within a gpu_class, users vary in how premium their OTHER components are (RAM, SSD, cores). A mid_range GPU paired with 32 cores + 2TB SSD is a 'premium' mid_range user. Without this var, hwc/screen/storage would be statistically independent given gpu_class — unrealistic (real users pick coherent bundles). Tier distribution biased: integrated_old mostly budget/standard, workstation mostly premium."
},
"table": {
"integrated_old": [{"value": "budget", "prob": 0.65}, {"value": "standard", "prob": 0.30}, {"value": "premium", "prob": 0.05}],
"integrated_modern": [{"value": "budget", "prob": 0.30}, {"value": "standard", "prob": 0.55}, {"value": "premium", "prob": 0.15}],
"low_end": [{"value": "budget", "prob": 0.50}, {"value": "standard", "prob": 0.40}, {"value": "premium", "prob": 0.10}],
"mid_range": [{"value": "budget", "prob": 0.25}, {"value": "standard", "prob": 0.50}, {"value": "premium", "prob": 0.25}],
"high_end": [{"value": "budget", "prob": 0.15}, {"value": "standard", "prob": 0.50}, {"value": "premium", "prob": 0.35}],
"workstation": [{"value": "budget", "prob": 0.10}, {"value": "standard", "prob": 0.40}, {"value": "premium", "prob": 0.50}]
}
}

View file

@ -1,16 +0,0 @@
{
"_meta": {
"name": "webgl.msaa-samples | gpu_class",
"parents": ["gpu_class"],
"child": "msaa_samples",
"source": "High-end GPUs more likely to have MSAA enabled in games; integrated usually off"
},
"table": {
"integrated_old": [{"value": 0, "prob": 0.85}, {"value": 2, "prob": 0.15}],
"integrated_modern": [{"value": 0, "prob": 0.70}, {"value": 2, "prob": 0.25}, {"value": 4, "prob": 0.05}],
"low_end": [{"value": 0, "prob": 0.55}, {"value": 2, "prob": 0.35}, {"value": 4, "prob": 0.10}],
"mid_range": [{"value": 0, "prob": 0.35}, {"value": 2, "prob": 0.35}, {"value": 4, "prob": 0.30}],
"high_end": [{"value": 0, "prob": 0.20}, {"value": 2, "prob": 0.25}, {"value": 4, "prob": 0.55}],
"workstation": [{"value": 0, "prob": 0.50}, {"value": 2, "prob": 0.25}, {"value": 4, "prob": 0.25}]
}
}

View file

@ -1,305 +0,0 @@
{
"_meta": "msaa_samples given (gpu_class, screen_tier)",
"table": {
"[\"integrated_old\", \"1080p\"]": [
{
"value": 2,
"prob": 0.75
},
{
"value": 4,
"prob": 0.2
},
{
"value": 8,
"prob": 0.05
}
],
"[\"integrated_old\", \"1440p\"]": [
{
"value": 2,
"prob": 0.85
},
{
"value": 4,
"prob": 0.15
}
],
"[\"integrated_old\", \"2160p\"]": [
{
"value": 2,
"prob": 1.0
}
],
"[\"integrated_old\", \"ultrawide\"]": [
{
"value": 2,
"prob": 1.0
}
],
"[\"integrated_modern\", \"1080p\"]": [
{
"value": 2,
"prob": 0.5
},
{
"value": 4,
"prob": 0.4
},
{
"value": 8,
"prob": 0.1
}
],
"[\"integrated_modern\", \"1440p\"]": [
{
"value": 2,
"prob": 0.6
},
{
"value": 4,
"prob": 0.35
},
{
"value": 8,
"prob": 0.05
}
],
"[\"integrated_modern\", \"2160p\"]": [
{
"value": 2,
"prob": 0.85
},
{
"value": 4,
"prob": 0.15
}
],
"[\"integrated_modern\", \"ultrawide\"]": [
{
"value": 2,
"prob": 0.8
},
{
"value": 4,
"prob": 0.2
}
],
"[\"low_end\", \"1080p\"]": [
{
"value": 2,
"prob": 0.4
},
{
"value": 4,
"prob": 0.45
},
{
"value": 8,
"prob": 0.15
}
],
"[\"low_end\", \"1440p\"]": [
{
"value": 2,
"prob": 0.55
},
{
"value": 4,
"prob": 0.4
},
{
"value": 8,
"prob": 0.05
}
],
"[\"low_end\", \"2160p\"]": [
{
"value": 2,
"prob": 0.85
},
{
"value": 4,
"prob": 0.15
}
],
"[\"low_end\", \"ultrawide\"]": [
{
"value": 2,
"prob": 0.7
},
{
"value": 4,
"prob": 0.3
}
],
"[\"mid_range\", \"1080p\"]": [
{
"value": 2,
"prob": 0.3
},
{
"value": 4,
"prob": 0.5
},
{
"value": 8,
"prob": 0.2
}
],
"[\"mid_range\", \"1440p\"]": [
{
"value": 2,
"prob": 0.4
},
{
"value": 4,
"prob": 0.45
},
{
"value": 8,
"prob": 0.15
}
],
"[\"mid_range\", \"2160p\"]": [
{
"value": 2,
"prob": 0.65
},
{
"value": 4,
"prob": 0.3
},
{
"value": 8,
"prob": 0.05
}
],
"[\"mid_range\", \"ultrawide\"]": [
{
"value": 2,
"prob": 0.55
},
{
"value": 4,
"prob": 0.4
},
{
"value": 8,
"prob": 0.05
}
],
"[\"high_end\", \"1080p\"]": [
{
"value": 2,
"prob": 0.2
},
{
"value": 4,
"prob": 0.45
},
{
"value": 8,
"prob": 0.35
}
],
"[\"high_end\", \"1440p\"]": [
{
"value": 2,
"prob": 0.25
},
{
"value": 4,
"prob": 0.5
},
{
"value": 8,
"prob": 0.25
}
],
"[\"high_end\", \"2160p\"]": [
{
"value": 2,
"prob": 0.4
},
{
"value": 4,
"prob": 0.45
},
{
"value": 8,
"prob": 0.15
}
],
"[\"high_end\", \"ultrawide\"]": [
{
"value": 2,
"prob": 0.3
},
{
"value": 4,
"prob": 0.5
},
{
"value": 8,
"prob": 0.2
}
],
"[\"workstation\", \"1080p\"]": [
{
"value": 2,
"prob": 0.15
},
{
"value": 4,
"prob": 0.5
},
{
"value": 8,
"prob": 0.35
}
],
"[\"workstation\", \"1440p\"]": [
{
"value": 2,
"prob": 0.15
},
{
"value": 4,
"prob": 0.55
},
{
"value": 8,
"prob": 0.3
}
],
"[\"workstation\", \"2160p\"]": [
{
"value": 2,
"prob": 0.25
},
{
"value": 4,
"prob": 0.55
},
{
"value": 8,
"prob": 0.2
}
],
"[\"workstation\", \"ultrawide\"]": [
{
"value": 2,
"prob": 0.2
},
{
"value": 4,
"prob": 0.55
},
{
"value": 8,
"prob": 0.25
}
]
}
}

View file

@ -1,54 +0,0 @@
{
"_meta": {
"name": "screen | gpu_class",
"parents": ["gpu_class"],
"child": "screen",
"value_shape": {"w": "int css px", "h": "int css px", "dpr": "float", "aw": "availWidth", "ah": "availHeight"},
"source": "Curated Windows desktop/laptop resolutions. Restricted to width >= 1920 to avoid FP Pro's server-side screen_resolution normalization (FP Pro sorts non-standard landscape resolutions like 1536x864 into [smaller, larger] order in raw_device_attributes.screen_resolution, appearing flipped vs our spoof). All retained resolutions are common and NOT normalized by FP Pro."
},
"table": {
"integrated_old": [
{"value": {"w": 1920, "h": 1080, "dpr": 1.0, "aw": 1920, "ah": 1040}, "prob": 0.90},
{"value": {"w": 1920, "h": 1200, "dpr": 1.0, "aw": 1920, "ah": 1160}, "prob": 0.10}
],
"integrated_modern": [
{"value": {"w": 1920, "h": 1080, "dpr": 1.0, "aw": 1920, "ah": 1040}, "prob": 0.60},
{"value": {"w": 1920, "h": 1080, "dpr": 1.25, "aw": 1920, "ah": 1040}, "prob": 0.18},
{"value": {"w": 2560, "h": 1440, "dpr": 1.0, "aw": 2560, "ah": 1400}, "prob": 0.10},
{"value": {"w": 1920, "h": 1200, "dpr": 1.0, "aw": 1920, "ah": 1160}, "prob": 0.08},
{"value": {"w": 2560, "h": 1600, "dpr": 1.25, "aw": 2560, "ah": 1560}, "prob": 0.04}
],
"low_end": [
{"value": {"w": 1920, "h": 1080, "dpr": 1.0, "aw": 1920, "ah": 1040}, "prob": 0.70},
{"value": {"w": 2560, "h": 1440, "dpr": 1.0, "aw": 2560, "ah": 1400}, "prob": 0.20},
{"value": {"w": 1920, "h": 1200, "dpr": 1.0, "aw": 1920, "ah": 1160}, "prob": 0.10}
],
"mid_range": [
{"value": {"w": 1920, "h": 1080, "dpr": 1.0, "aw": 1920, "ah": 1040}, "prob": 0.50},
{"value": {"w": 2560, "h": 1440, "dpr": 1.0, "aw": 2560, "ah": 1400}, "prob": 0.25},
{"value": {"w": 1920, "h": 1080, "dpr": 1.25, "aw": 1920, "ah": 1040}, "prob": 0.08},
{"value": {"w": 2560, "h": 1440, "dpr": 1.25, "aw": 2560, "ah": 1400}, "prob": 0.05},
{"value": {"w": 3840, "h": 2160, "dpr": 1.0, "aw": 3840, "ah": 2120}, "prob": 0.04},
{"value": {"w": 3840, "h": 2160, "dpr": 1.5, "aw": 3840, "ah": 2120}, "prob": 0.04},
{"value": {"w": 2560, "h": 1080, "dpr": 1.0, "aw": 2560, "ah": 1040}, "prob": 0.04}
],
"high_end": [
{"value": {"w": 1920, "h": 1080, "dpr": 1.0, "aw": 1920, "ah": 1040}, "prob": 0.25},
{"value": {"w": 2560, "h": 1440, "dpr": 1.0, "aw": 2560, "ah": 1400}, "prob": 0.30},
{"value": {"w": 3840, "h": 2160, "dpr": 1.0, "aw": 3840, "ah": 2120}, "prob": 0.15},
{"value": {"w": 3840, "h": 2160, "dpr": 1.5, "aw": 3840, "ah": 2120}, "prob": 0.10},
{"value": {"w": 3440, "h": 1440, "dpr": 1.0, "aw": 3440, "ah": 1400}, "prob": 0.08},
{"value": {"w": 2560, "h": 1440, "dpr": 1.25, "aw": 2560, "ah": 1400}, "prob": 0.05},
{"value": {"w": 5120, "h": 1440, "dpr": 1.0, "aw": 5120, "ah": 1400}, "prob": 0.04},
{"value": {"w": 3840, "h": 2160, "dpr": 2.0, "aw": 3840, "ah": 2120}, "prob": 0.03}
],
"workstation": [
{"value": {"w": 1920, "h": 1080, "dpr": 1.0, "aw": 1920, "ah": 1040}, "prob": 0.25},
{"value": {"w": 2560, "h": 1440, "dpr": 1.0, "aw": 2560, "ah": 1400}, "prob": 0.25},
{"value": {"w": 3840, "h": 2160, "dpr": 1.0, "aw": 3840, "ah": 2120}, "prob": 0.25},
{"value": {"w": 3840, "h": 2160, "dpr": 1.5, "aw": 3840, "ah": 2120}, "prob": 0.15},
{"value": {"w": 5120, "h": 2880, "dpr": 1.0, "aw": 5120, "ah": 2840}, "prob": 0.05},
{"value": {"w": 3840, "h": 2400, "dpr": 1.5, "aw": 3840, "ah": 2360}, "prob": 0.05}
]
}
}

View file

@ -1,761 +0,0 @@
{
"_meta": "screen given (gpu_class, intra_tier)",
"table": {
"[\"integrated_old\", \"budget\"]": [
{
"value": {
"w": 1366,
"h": 768,
"aw": 1366,
"ah": 728,
"dpr": 1.0
},
"prob": 0.78
},
{
"value": {
"w": 1280,
"h": 800,
"aw": 1280,
"ah": 760,
"dpr": 1.0
},
"prob": 0.15
},
{
"value": {
"w": 1024,
"h": 768,
"aw": 1024,
"ah": 728,
"dpr": 1.0
},
"prob": 0.07
}
],
"[\"integrated_old\", \"standard\"]": [
{
"value": {
"w": 1366,
"h": 768,
"aw": 1366,
"ah": 728,
"dpr": 1.0
},
"prob": 0.55
},
{
"value": {
"w": 1600,
"h": 900,
"aw": 1600,
"ah": 860,
"dpr": 1.0
},
"prob": 0.25
},
{
"value": {
"w": 1280,
"h": 800,
"aw": 1280,
"ah": 760,
"dpr": 1.0
},
"prob": 0.1
},
{
"value": {
"w": 1920,
"h": 1080,
"aw": 1920,
"ah": 1040,
"dpr": 1.0
},
"prob": 0.1
}
],
"[\"integrated_old\", \"premium\"]": [
{
"value": {
"w": 1600,
"h": 900,
"aw": 1600,
"ah": 860,
"dpr": 1.0
},
"prob": 0.45
},
{
"value": {
"w": 1920,
"h": 1080,
"aw": 1920,
"ah": 1040,
"dpr": 1.0
},
"prob": 0.4
},
{
"value": {
"w": 1366,
"h": 768,
"aw": 1366,
"ah": 728,
"dpr": 1.0
},
"prob": 0.15
}
],
"[\"integrated_modern\", \"budget\"]": [
{
"value": {
"w": 1920,
"h": 1080,
"aw": 1920,
"ah": 1040,
"dpr": 1.0
},
"prob": 0.8
},
{
"value": {
"w": 2560,
"h": 1440,
"aw": 2560,
"ah": 1400,
"dpr": 1.0
},
"prob": 0.15
},
{
"value": {
"w": 1920,
"h": 1200,
"aw": 1920,
"ah": 1160,
"dpr": 1.0
},
"prob": 0.05
}
],
"[\"integrated_modern\", \"standard\"]": [
{
"value": {
"w": 1920,
"h": 1080,
"aw": 1920,
"ah": 1040,
"dpr": 1.0
},
"prob": 0.7
},
{
"value": {
"w": 1920,
"h": 1200,
"aw": 1920,
"ah": 1160,
"dpr": 1.0
},
"prob": 0.1
},
{
"value": {
"w": 2560,
"h": 1440,
"aw": 2560,
"ah": 1400,
"dpr": 1.0
},
"prob": 0.12
},
{
"value": {
"w": 2560,
"h": 1600,
"aw": 2560,
"ah": 1560,
"dpr": 1.0
},
"prob": 0.08
}
],
"[\"integrated_modern\", \"premium\"]": [
{
"value": {
"w": 1920,
"h": 1080,
"aw": 1920,
"ah": 1040,
"dpr": 1.0
},
"prob": 0.3
},
{
"value": {
"w": 1920,
"h": 1200,
"aw": 1920,
"ah": 1160,
"dpr": 1.0
},
"prob": 0.1
},
{
"value": {
"w": 2560,
"h": 1440,
"aw": 2560,
"ah": 1400,
"dpr": 1.0
},
"prob": 0.25
},
{
"value": {
"w": 2560,
"h": 1600,
"aw": 2560,
"ah": 1560,
"dpr": 1.0
},
"prob": 0.15
},
{
"value": {
"w": 3840,
"h": 2160,
"aw": 3840,
"ah": 2120,
"dpr": 1.0
},
"prob": 0.2
}
],
"[\"low_end\", \"budget\"]": [
{
"value": {
"w": 1920,
"h": 1080,
"aw": 1920,
"ah": 1040,
"dpr": 1.0
},
"prob": 0.85
},
{
"value": {
"w": 1920,
"h": 1200,
"aw": 1920,
"ah": 1160,
"dpr": 1.0
},
"prob": 0.1
},
{
"value": {
"w": 2560,
"h": 1440,
"aw": 2560,
"ah": 1400,
"dpr": 1.0
},
"prob": 0.05
}
],
"[\"low_end\", \"standard\"]": [
{
"value": {
"w": 1920,
"h": 1080,
"aw": 1920,
"ah": 1040,
"dpr": 1.0
},
"prob": 0.6
},
{
"value": {
"w": 1920,
"h": 1200,
"aw": 1920,
"ah": 1160,
"dpr": 1.0
},
"prob": 0.1
},
{
"value": {
"w": 2560,
"h": 1440,
"aw": 2560,
"ah": 1400,
"dpr": 1.0
},
"prob": 0.25
},
{
"value": {
"w": 3840,
"h": 2160,
"aw": 3840,
"ah": 2120,
"dpr": 1.0
},
"prob": 0.05
}
],
"[\"low_end\", \"premium\"]": [
{
"value": {
"w": 1920,
"h": 1080,
"aw": 1920,
"ah": 1040,
"dpr": 1.0
},
"prob": 0.25
},
{
"value": {
"w": 2560,
"h": 1440,
"aw": 2560,
"ah": 1400,
"dpr": 1.0
},
"prob": 0.45
},
{
"value": {
"w": 3840,
"h": 2160,
"aw": 3840,
"ah": 2120,
"dpr": 1.0
},
"prob": 0.2
},
{
"value": {
"w": 3440,
"h": 1440,
"aw": 3440,
"ah": 1400,
"dpr": 1.0
},
"prob": 0.05
},
{
"value": {
"w": 1920,
"h": 1200,
"aw": 1920,
"ah": 1160,
"dpr": 1.0
},
"prob": 0.05
}
],
"[\"mid_range\", \"budget\"]": [
{
"value": {
"w": 1920,
"h": 1080,
"aw": 1920,
"ah": 1040,
"dpr": 1.0
},
"prob": 0.75
},
{
"value": {
"w": 2560,
"h": 1440,
"aw": 2560,
"ah": 1400,
"dpr": 1.0
},
"prob": 0.2
},
{
"value": {
"w": 1920,
"h": 1200,
"aw": 1920,
"ah": 1160,
"dpr": 1.0
},
"prob": 0.05
}
],
"[\"mid_range\", \"standard\"]": [
{
"value": {
"w": 1920,
"h": 1080,
"aw": 1920,
"ah": 1040,
"dpr": 1.0
},
"prob": 0.45
},
{
"value": {
"w": 2560,
"h": 1440,
"aw": 2560,
"ah": 1400,
"dpr": 1.0
},
"prob": 0.4
},
{
"value": {
"w": 3840,
"h": 2160,
"aw": 3840,
"ah": 2120,
"dpr": 1.0
},
"prob": 0.1
},
{
"value": {
"w": 3440,
"h": 1440,
"aw": 3440,
"ah": 1400,
"dpr": 1.0
},
"prob": 0.05
}
],
"[\"mid_range\", \"premium\"]": [
{
"value": {
"w": 1920,
"h": 1080,
"aw": 1920,
"ah": 1040,
"dpr": 1.0
},
"prob": 0.15
},
{
"value": {
"w": 2560,
"h": 1440,
"aw": 2560,
"ah": 1400,
"dpr": 1.0
},
"prob": 0.5
},
{
"value": {
"w": 3840,
"h": 2160,
"aw": 3840,
"ah": 2120,
"dpr": 1.0
},
"prob": 0.2
},
{
"value": {
"w": 3440,
"h": 1440,
"aw": 3440,
"ah": 1400,
"dpr": 1.0
},
"prob": 0.1
},
{
"value": {
"w": 2560,
"h": 1080,
"aw": 2560,
"ah": 1040,
"dpr": 1.0
},
"prob": 0.05
}
],
"[\"high_end\", \"budget\"]": [
{
"value": {
"w": 1920,
"h": 1080,
"aw": 1920,
"ah": 1040,
"dpr": 1.0
},
"prob": 0.2
},
{
"value": {
"w": 2560,
"h": 1440,
"aw": 2560,
"ah": 1400,
"dpr": 1.0
},
"prob": 0.55
},
{
"value": {
"w": 3840,
"h": 2160,
"aw": 3840,
"ah": 2120,
"dpr": 1.0
},
"prob": 0.2
},
{
"value": {
"w": 3440,
"h": 1440,
"aw": 3440,
"ah": 1400,
"dpr": 1.0
},
"prob": 0.05
}
],
"[\"high_end\", \"standard\"]": [
{
"value": {
"w": 2560,
"h": 1440,
"aw": 2560,
"ah": 1400,
"dpr": 1.0
},
"prob": 0.4
},
{
"value": {
"w": 3840,
"h": 2160,
"aw": 3840,
"ah": 2120,
"dpr": 1.0
},
"prob": 0.4
},
{
"value": {
"w": 3440,
"h": 1440,
"aw": 3440,
"ah": 1400,
"dpr": 1.0
},
"prob": 0.15
},
{
"value": {
"w": 5120,
"h": 1440,
"aw": 5120,
"ah": 1400,
"dpr": 1.0
},
"prob": 0.05
}
],
"[\"high_end\", \"premium\"]": [
{
"value": {
"w": 3840,
"h": 2160,
"aw": 3840,
"ah": 2120,
"dpr": 1.0
},
"prob": 0.55
},
{
"value": {
"w": 2560,
"h": 1440,
"aw": 2560,
"ah": 1400,
"dpr": 1.0
},
"prob": 0.15
},
{
"value": {
"w": 3440,
"h": 1440,
"aw": 3440,
"ah": 1400,
"dpr": 1.0
},
"prob": 0.1
},
{
"value": {
"w": 5120,
"h": 1440,
"aw": 5120,
"ah": 1400,
"dpr": 1.0
},
"prob": 0.15
},
{
"value": {
"w": 7680,
"h": 2160,
"aw": 7680,
"ah": 2120,
"dpr": 1.0
},
"prob": 0.05
}
],
"[\"workstation\", \"budget\"]": [
{
"value": {
"w": 2560,
"h": 1440,
"aw": 2560,
"ah": 1400,
"dpr": 1.0
},
"prob": 0.55
},
{
"value": {
"w": 3840,
"h": 2160,
"aw": 3840,
"ah": 2120,
"dpr": 1.0
},
"prob": 0.3
},
{
"value": {
"w": 1920,
"h": 1200,
"aw": 1920,
"ah": 1160,
"dpr": 1.0
},
"prob": 0.1
},
{
"value": {
"w": 2560,
"h": 1600,
"aw": 2560,
"ah": 1560,
"dpr": 1.0
},
"prob": 0.05
}
],
"[\"workstation\", \"standard\"]": [
{
"value": {
"w": 2560,
"h": 1440,
"aw": 2560,
"ah": 1400,
"dpr": 1.0
},
"prob": 0.3
},
{
"value": {
"w": 3840,
"h": 2160,
"aw": 3840,
"ah": 2120,
"dpr": 1.0
},
"prob": 0.45
},
{
"value": {
"w": 2560,
"h": 1600,
"aw": 2560,
"ah": 1560,
"dpr": 1.0
},
"prob": 0.15
},
{
"value": {
"w": 3440,
"h": 1440,
"aw": 3440,
"ah": 1400,
"dpr": 1.0
},
"prob": 0.1
}
],
"[\"workstation\", \"premium\"]": [
{
"value": {
"w": 3840,
"h": 2160,
"aw": 3840,
"ah": 2120,
"dpr": 1.0
},
"prob": 0.55
},
{
"value": {
"w": 2560,
"h": 1600,
"aw": 2560,
"ah": 1560,
"dpr": 1.0
},
"prob": 0.15
},
{
"value": {
"w": 5120,
"h": 2160,
"aw": 5120,
"ah": 2120,
"dpr": 1.0
},
"prob": 0.15
},
{
"value": {
"w": 3840,
"h": 2400,
"aw": 3840,
"ah": 2360,
"dpr": 1.0
},
"prob": 0.1
},
{
"value": {
"w": 7680,
"h": 2160,
"aw": 7680,
"ah": 2120,
"dpr": 1.0
},
"prob": 0.05
}
]
}
}

View file

@ -1,60 +0,0 @@
{
"_meta": {
"name": "storage_quota_mb | gpu_class",
"parents": ["gpu_class"],
"child": "storage_quota_mb",
"value_shape": "int megabytes (reported as navigator.storage.estimate().quota / (1024*1024))",
"description": "Storage quota override returned by navigator.storage.estimate(). Real Firefox computes quota from disk space, which is a fingerprinting signal (stable per host). We replace it with a realistic per-session value conditioned on gpu_class — modern/high-end users tend to have larger SSDs, integrated_old older/smaller disks.",
"source": "Typical Windows 11 disk sizes 2024-2026 per segment: budget laptops 256-512GB SSD, mainstream 512GB-1TB SSD, gaming/workstation 1-4TB SSD. Firefox default storage quota is ~50% of available disk (capped). We simulate that computation per-tier.",
"rationale": "Values are pre-quota-limit MB as Firefox would return to navigator.storage.estimate().quota. Probabilities cover realistic ranges centered on the segment median. A single session returns one value; fingerprinters that hash the exact quota get per-session variance, matching cross-user diversity."
},
"table": {
"integrated_old": [
{"value": 61440, "prob": 0.30},
{"value": 102400, "prob": 0.25},
{"value": 122880, "prob": 0.20},
{"value": 204800, "prob": 0.15},
{"value": 40960, "prob": 0.10}
],
"integrated_modern": [
{"value": 204800, "prob": 0.30},
{"value": 307200, "prob": 0.25},
{"value": 122880, "prob": 0.15},
{"value": 409600, "prob": 0.15},
{"value": 102400, "prob": 0.10},
{"value": 512000, "prob": 0.05}
],
"low_end": [
{"value": 122880, "prob": 0.30},
{"value": 204800, "prob": 0.25},
{"value": 102400, "prob": 0.20},
{"value": 61440, "prob": 0.15},
{"value": 307200, "prob": 0.10}
],
"mid_range": [
{"value": 307200, "prob": 0.30},
{"value": 409600, "prob": 0.25},
{"value": 204800, "prob": 0.20},
{"value": 512000, "prob": 0.15},
{"value": 716800, "prob": 0.05},
{"value": 122880, "prob": 0.05}
],
"high_end": [
{"value": 512000, "prob": 0.25},
{"value": 716800, "prob": 0.25},
{"value": 409600, "prob": 0.20},
{"value": 1024000, "prob": 0.15},
{"value": 307200, "prob": 0.10},
{"value": 1536000, "prob": 0.05}
],
"workstation": [
{"value": 1024000, "prob": 0.25},
{"value": 1536000, "prob": 0.20},
{"value": 716800, "prob": 0.20},
{"value": 2048000, "prob": 0.15},
{"value": 512000, "prob": 0.10},
{"value": 3072000, "prob": 0.05},
{"value": 409600, "prob": 0.05}
]
}
}

View file

@ -1,297 +0,0 @@
{
"_meta": "storage_quota_mb given (gpu_class, intra_tier)",
"table": {
"[\"integrated_old\", \"budget\"]": [
{
"value": 32000,
"prob": 0.3
},
{
"value": 64000,
"prob": 0.4
},
{
"value": 128000,
"prob": 0.25
},
{
"value": 256000,
"prob": 0.05
}
],
"[\"integrated_old\", \"standard\"]": [
{
"value": 64000,
"prob": 0.2
},
{
"value": 128000,
"prob": 0.45
},
{
"value": 256000,
"prob": 0.25
},
{
"value": 500000,
"prob": 0.1
}
],
"[\"integrated_old\", \"premium\"]": [
{
"value": 128000,
"prob": 0.2
},
{
"value": 256000,
"prob": 0.45
},
{
"value": 500000,
"prob": 0.35
}
],
"[\"integrated_modern\", \"budget\"]": [
{
"value": 256000,
"prob": 0.3
},
{
"value": 500000,
"prob": 0.45
},
{
"value": 1000000,
"prob": 0.25
}
],
"[\"integrated_modern\", \"standard\"]": [
{
"value": 256000,
"prob": 0.25
},
{
"value": 500000,
"prob": 0.45
},
{
"value": 1000000,
"prob": 0.25
},
{
"value": 2000000,
"prob": 0.05
}
],
"[\"integrated_modern\", \"premium\"]": [
{
"value": 500000,
"prob": 0.25
},
{
"value": 1000000,
"prob": 0.5
},
{
"value": 2000000,
"prob": 0.2
},
{
"value": 4000000,
"prob": 0.05
}
],
"[\"low_end\", \"budget\"]": [
{
"value": 128000,
"prob": 0.2
},
{
"value": 256000,
"prob": 0.5
},
{
"value": 500000,
"prob": 0.25
},
{
"value": 1000000,
"prob": 0.05
}
],
"[\"low_end\", \"standard\"]": [
{
"value": 256000,
"prob": 0.2
},
{
"value": 500000,
"prob": 0.5
},
{
"value": 1000000,
"prob": 0.25
},
{
"value": 2000000,
"prob": 0.05
}
],
"[\"low_end\", \"premium\"]": [
{
"value": 500000,
"prob": 0.2
},
{
"value": 1000000,
"prob": 0.5
},
{
"value": 2000000,
"prob": 0.25
},
{
"value": 4000000,
"prob": 0.05
}
],
"[\"mid_range\", \"budget\"]": [
{
"value": 256000,
"prob": 0.15
},
{
"value": 500000,
"prob": 0.5
},
{
"value": 1000000,
"prob": 0.3
},
{
"value": 2000000,
"prob": 0.05
}
],
"[\"mid_range\", \"standard\"]": [
{
"value": 500000,
"prob": 0.2
},
{
"value": 1000000,
"prob": 0.55
},
{
"value": 2000000,
"prob": 0.2
},
{
"value": 4000000,
"prob": 0.05
}
],
"[\"mid_range\", \"premium\"]": [
{
"value": 1000000,
"prob": 0.4
},
{
"value": 2000000,
"prob": 0.45
},
{
"value": 4000000,
"prob": 0.15
}
],
"[\"high_end\", \"budget\"]": [
{
"value": 500000,
"prob": 0.15
},
{
"value": 1000000,
"prob": 0.5
},
{
"value": 2000000,
"prob": 0.3
},
{
"value": 4000000,
"prob": 0.05
}
],
"[\"high_end\", \"standard\"]": [
{
"value": 1000000,
"prob": 0.3
},
{
"value": 2000000,
"prob": 0.5
},
{
"value": 4000000,
"prob": 0.2
}
],
"[\"high_end\", \"premium\"]": [
{
"value": 2000000,
"prob": 0.4
},
{
"value": 4000000,
"prob": 0.45
},
{
"value": 8000000,
"prob": 0.15
}
],
"[\"workstation\", \"budget\"]": [
{
"value": 1000000,
"prob": 0.3
},
{
"value": 2000000,
"prob": 0.5
},
{
"value": 4000000,
"prob": 0.2
}
],
"[\"workstation\", \"standard\"]": [
{
"value": 2000000,
"prob": 0.3
},
{
"value": 4000000,
"prob": 0.5
},
{
"value": 8000000,
"prob": 0.2
}
],
"[\"workstation\", \"premium\"]": [
{
"value": 4000000,
"prob": 0.35
},
{
"value": 8000000,
"prob": 0.45
},
{
"value": 16000000,
"prob": 0.2
}
]
}
}

View file

@ -1,650 +0,0 @@
{
"_source": "browserforge fingerprint-network.zip, FF/Windows slice, 3 userAgent leaves merged",
"_ff_win_uas": [
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:145.0) Gecko/20100101 Firefox/145.0",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:135.0) Gecko/20100101 Firefox/135.0",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0"
],
"_note": "Per-node weighted distributions. Marginal over the 3 Firefox/Windows UAs present in browserforge data.",
"distributions": {
"appCodeName": [
[
"Mozilla",
1.0
]
],
"appVersion": [
[
"5.0 (Windows)",
1.0
]
],
"doNotTrack": [
[
"1",
1.0
]
],
"extraProperties": [
[
{
"vendorFlavors": [],
"globalPrivacyControl": true,
"pdfViewerEnabled": true,
"installedApps": []
},
0.6111111111111112
],
[
{
"vendorFlavors": [],
"globalPrivacyControl": null,
"pdfViewerEnabled": null,
"installedApps": []
},
0.3888888888888889
]
],
"maxTouchPoints": [
[
0,
0.5777777777777778
],
[
1,
0.3666666666666667
],
[
10,
0.05555555555555555
]
],
"oscpu": [
[
"Windows NT 10.0; Win64; x64",
1.0
]
],
"webdriver": [
[
false,
1.0
]
],
"productSub": [
[
"20100101",
1.0
]
],
"hardwareConcurrency": [
[
12,
0.3333333333333333
],
[
16,
0.2777777777777778
],
[
8,
0.13333333333333333
],
[
24,
0.13333333333333333
],
[
32,
0.06666666666666667
],
[
6,
0.05555555555555555
]
],
"platform": [
[
"Win32",
1.0
]
],
"screen": [
[
{
"availTop": 0,
"availLeft": 0,
"pageXOffset": 0,
"pageYOffset": 0,
"screenX": 772,
"hasHDR": false,
"width": 1536,
"height": 960,
"availWidth": 1536,
"availHeight": 960,
"clientWidth": 0,
"clientHeight": 21,
"innerWidth": 0,
"innerHeight": 0,
"outerWidth": 751,
"outerHeight": 886,
"colorDepth": 24,
"pixelDepth": 24,
"devicePixelRatio": 1.25
},
0.3333333333333333
],
[
{
"availTop": 0,
"availLeft": 2176,
"pageXOffset": 0,
"pageYOffset": 0,
"screenX": 2169,
"hasHDR": false,
"width": 2176,
"height": 1224,
"availWidth": 2176,
"availHeight": 1176,
"clientWidth": 0,
"clientHeight": 19,
"innerWidth": 0,
"innerHeight": 0,
"outerWidth": 2190,
"outerHeight": 1190,
"colorDepth": 24,
"pixelDepth": 24,
"devicePixelRatio": 1.7647058823529411
},
0.13333333333333333
],
[
{
"availTop": 0,
"availLeft": 0,
"pageXOffset": 0,
"pageYOffset": 0,
"screenX": 318,
"hasHDR": false,
"width": 2560,
"height": 1440,
"availWidth": 2560,
"availHeight": 1440,
"clientWidth": 0,
"clientHeight": 18,
"innerWidth": 0,
"innerHeight": 0,
"outerWidth": 2121,
"outerHeight": 1191,
"colorDepth": 24,
"pixelDepth": 24,
"devicePixelRatio": 1
},
0.1111111111111111
],
[
{
"availTop": 0,
"availLeft": 0,
"pageXOffset": 0,
"pageYOffset": 0,
"screenX": 652,
"hasHDR": false,
"width": 2752,
"height": 1152,
"availWidth": 2752,
"availHeight": 1104,
"clientWidth": 0,
"clientHeight": 19,
"innerWidth": 0,
"innerHeight": 0,
"outerWidth": 1806,
"outerHeight": 1104,
"colorDepth": 24,
"pixelDepth": 24,
"devicePixelRatio": 1.25
},
0.06666666666666667
],
[
{
"availTop": 0,
"availLeft": 0,
"pageXOffset": 0,
"pageYOffset": 0,
"screenX": 1912,
"hasHDR": false,
"width": 1920,
"height": 1080,
"availWidth": 1920,
"availHeight": 1032,
"clientWidth": 0,
"clientHeight": 18,
"innerWidth": 0,
"innerHeight": 0,
"outerWidth": 1936,
"outerHeight": 1048,
"colorDepth": 24,
"pixelDepth": 24,
"devicePixelRatio": 1
},
0.06666666666666667
],
[
{
"availTop": 29,
"availLeft": 21,
"pageXOffset": 0,
"pageYOffset": 0,
"screenX": 15,
"hasHDR": false,
"width": 1376,
"height": 774,
"availWidth": 1355,
"availHeight": 745,
"clientWidth": 0,
"clientHeight": 21,
"innerWidth": 0,
"innerHeight": 0,
"outerWidth": 1352,
"outerHeight": 749,
"colorDepth": 24,
"pixelDepth": 24,
"devicePixelRatio": 1
},
0.05555555555555555
],
[
{
"availTop": 0,
"availLeft": 0,
"pageXOffset": 0,
"pageYOffset": 0,
"screenX": 58,
"hasHDR": false,
"width": 1707,
"height": 960,
"availWidth": 1707,
"availHeight": 928,
"clientWidth": 0,
"clientHeight": 19,
"innerWidth": 0,
"innerHeight": 0,
"outerWidth": 1564,
"outerHeight": 793,
"colorDepth": 24,
"pixelDepth": 24,
"devicePixelRatio": 1
},
0.05555555555555555
],
[
{
"availTop": 0,
"availLeft": 0,
"pageXOffset": 0,
"pageYOffset": 0,
"screenX": 34,
"hasHDR": false,
"width": 5120,
"height": 1440,
"availWidth": 5120,
"availHeight": 1392,
"clientWidth": 0,
"clientHeight": 18,
"innerWidth": 0,
"innerHeight": 0,
"outerWidth": 2462,
"outerHeight": 1399,
"colorDepth": 24,
"pixelDepth": 24,
"devicePixelRatio": 1
},
0.05555555555555555
],
[
{
"availTop": 0,
"availLeft": 1920,
"pageXOffset": 0,
"pageYOffset": 0,
"screenX": 1912,
"hasHDR": false,
"width": 1920,
"height": 1080,
"availWidth": 1920,
"availHeight": 1032,
"clientWidth": 0,
"clientHeight": 18,
"innerWidth": 0,
"innerHeight": 0,
"outerWidth": 1936,
"outerHeight": 1048,
"colorDepth": 24,
"pixelDepth": 24,
"devicePixelRatio": 1
},
0.05555555555555555
],
[
{
"availTop": 0,
"availLeft": 0,
"pageXOffset": 0,
"pageYOffset": 0,
"screenX": 95,
"hasHDR": false,
"width": 1366,
"height": 768,
"availWidth": 1366,
"availHeight": 768,
"clientWidth": 0,
"clientHeight": 18,
"innerWidth": 0,
"innerHeight": 0,
"outerWidth": 1294,
"outerHeight": 1280,
"colorDepth": 24,
"pixelDepth": 24,
"devicePixelRatio": 1
},
0.03333333333333333
],
[
{
"availTop": 0,
"availLeft": 0,
"pageXOffset": 0,
"pageYOffset": 0,
"screenX": 1273,
"hasHDR": false,
"width": 2560,
"height": 1440,
"availWidth": 2560,
"availHeight": 1400,
"clientWidth": 0,
"clientHeight": 18,
"innerWidth": 0,
"innerHeight": 0,
"outerWidth": 1294,
"outerHeight": 1407,
"colorDepth": 24,
"pixelDepth": 24,
"devicePixelRatio": 1
},
0.03333333333333333
]
],
"pluginsData": [
[
{
"plugins": [
{
"name": "PDF Viewer",
"description": "Portable Document Format",
"filename": "internal-pdf-viewer",
"mimeTypes": [
{
"type": "application/pdf",
"suffixes": "pdf",
"description": "Portable Document Format",
"enabledPlugin": "PDF Viewer"
},
{
"type": "text/pdf",
"suffixes": "pdf",
"description": "Portable Document Format",
"enabledPlugin": "PDF Viewer"
}
]
},
{
"name": "Chrome PDF Viewer",
"description": "Portable Document Format",
"filename": "internal-pdf-viewer",
"mimeTypes": [
{
"type": "application/pdf",
"suffixes": "pdf",
"description": "Portable Document Format",
"enabledPlugin": "PDF Viewer"
},
{
"type": "text/pdf",
"suffixes": "pdf",
"description": "Portable Document Format",
"enabledPlugin": "PDF Viewer"
}
]
},
{
"name": "Chromium PDF Viewer",
"description": "Portable Document Format",
"filename": "internal-pdf-viewer",
"mimeTypes": [
{
"type": "application/pdf",
"suffixes": "pdf",
"description": "Portable Document Format",
"enabledPlugin": "PDF Viewer"
},
{
"type": "text/pdf",
"suffixes": "pdf",
"description": "Portable Document Format",
"enabledPlugin": "PDF Viewer"
}
]
},
{
"name": "Microsoft Edge PDF Viewer",
"description": "Portable Document Format",
"filename": "internal-pdf-viewer",
"mimeTypes": [
{
"type": "application/pdf",
"suffixes": "pdf",
"description": "Portable Document Format",
"enabledPlugin": "PDF Viewer"
},
{
"type": "text/pdf",
"suffixes": "pdf",
"description": "Portable Document Format",
"enabledPlugin": "PDF Viewer"
}
]
},
{
"name": "WebKit built-in PDF",
"description": "Portable Document Format",
"filename": "internal-pdf-viewer",
"mimeTypes": [
{
"type": "application/pdf",
"suffixes": "pdf",
"description": "Portable Document Format",
"enabledPlugin": "PDF Viewer"
},
{
"type": "text/pdf",
"suffixes": "pdf",
"description": "Portable Document Format",
"enabledPlugin": "PDF Viewer"
}
]
}
],
"mimeTypes": [
"Portable Document Format~~application/pdf~~pdf",
"Portable Document Format~~text/pdf~~pdf"
]
},
0.6111111111111112
],
[
{
"plugins": [],
"mimeTypes": []
},
0.3888888888888889
]
],
"audioCodecs": [
[
{
"ogg": "probably",
"mp3": "maybe",
"wav": "probably",
"m4a": "maybe",
"aac": "maybe"
},
1.0
]
],
"videoCodecs": [
[
{
"ogg": "",
"h264": "probably",
"webm": "probably"
},
1.0
]
],
"videoCard": [
[
{
"renderer": "ANGLE (NVIDIA, NVIDIA GeForce GTX 980 Direct3D11 vs_5_0 ps_5_0), or similar",
"vendor": "Google Inc. (NVIDIA)"
},
0.8444444444444444
],
[
{
"renderer": "ANGLE (AMD, Radeon R9 200 Series Direct3D11 vs_5_0 ps_5_0), or similar",
"vendor": "Google Inc. (AMD)"
},
0.08888888888888889
],
[
{
"renderer": "ANGLE (Intel, Intel(R) HD Graphics 400 Direct3D11 vs_5_0 ps_5_0), or similar",
"vendor": "Google Inc. (Intel)"
},
0.06666666666666667
]
],
"multimediaDevices": [
[
{
"speakers": [],
"micros": [],
"webcams": []
},
1.0
]
],
"fonts": [
[
[],
0.3888888888888889
],
[
[
"Calibri",
"HELV",
"MS UI Gothic",
"Marlett",
"Segoe UI Light",
"Small Fonts"
],
0.3222222222222222
],
[
[
"Agency FB",
"Calibri",
"Century",
"Century Gothic",
"Franklin Gothic",
"HELV",
"Haettenschweiler",
"Leelawadee",
"Lucida Bright",
"Lucida Sans",
"MS Outlook",
"MS Reference Specialty",
"MS UI Gothic",
"MT Extra",
"Marlett",
"Microsoft Uighur",
"Monotype Corsiva",
"Pristina",
"Segoe UI Light",
"Small Fonts"
],
0.13333333333333333
],
[
[
"AvantGarde Bk BT",
"Calibri",
"Clarendon",
"Franklin Gothic",
"Futura Bk BT",
"Futura Md BT",
"GOTHAM",
"HELV",
"Humanst521 BT",
"MS UI Gothic",
"MYRIAD PRO",
"Marlett",
"Minion Pro",
"Segoe UI Light",
"Small Fonts",
"TRAJAN PRO"
],
0.06666666666666667
],
[
[
"Calibri",
"MS UI Gothic",
"Marlett",
"Segoe UI Light"
],
0.05555555555555555
],
[
[
"Agency FB",
"Calibri",
"Century",
"Century Gothic",
"Franklin Gothic",
"Futura Bk BT",
"Futura Md BT",
"GOTHAM",
"HELV",
"Haettenschweiler",
"Humanst521 BT",
"Lucida Bright",
"Lucida Sans",
"MS Outlook",
"MS Reference Specialty",
"MS UI Gothic",
"MT Extra",
"MYRIAD PRO",
"Marlett",
"Minion Pro",
"Monotype Corsiva",
"Pristina",
"Segoe UI Light",
"Small Fonts"
],
0.03333333333333333
]
]
}
}

View file

@ -1,670 +0,0 @@
{
"core": [
{
"name": "arial",
"factor": 0.978
},
{
"name": "arial black",
"factor": 1.168
},
{
"name": "arial narrow",
"factor": 0.854
},
{
"name": "bahnschrift",
"factor": 0.951
},
{
"name": "bahnschrift condensed",
"factor": 1.179
},
{
"name": "bahnschrift light",
"factor": 0.908
},
{
"name": "bahnschrift light condensed",
"factor": 1.053
},
{
"name": "bahnschrift light semicondensed",
"factor": 1.063
},
{
"name": "bahnschrift semibold",
"factor": 0.926
},
{
"name": "bahnschrift semibold condensed",
"factor": 1.131
},
{
"name": "bahnschrift semibold semicondensed",
"factor": 1.043
},
{
"name": "bahnschrift semicondensed",
"factor": 1.098
},
{
"name": "bahnschrift semilight",
"factor": 0.873
},
{
"name": "bahnschrift semilight condensed",
"factor": 0.905
},
{
"name": "bahnschrift semilight semicondensed",
"factor": 1.135
},
{
"name": "calibri light",
"factor": 0.901
},
{
"name": "cambria",
"factor": 1.063
},
{
"name": "cambria math",
"factor": 1.041
},
{
"name": "candara",
"factor": 0.927
},
{
"name": "candara light",
"factor": 0.892
},
{
"name": "cascadia code",
"factor": 1.139
},
{
"name": "cascadia mono",
"factor": 0.95
},
{
"name": "comic sans ms",
"factor": 1.087
},
{
"name": "consolas",
"factor": 1.158
},
{
"name": "constantia",
"factor": 1.052
},
{
"name": "corbel",
"factor": 0.895
},
{
"name": "corbel light",
"factor": 0.876
},
{
"name": "courier new",
"factor": 1.187
},
{
"name": "ebrima",
"factor": 0.962
},
{
"name": "franklin gothic medium",
"factor": 1.031
},
{
"name": "gabriola",
"factor": 1.077
},
{
"name": "georgia",
"factor": 1.094
},
{
"name": "hololens mdl2 assets",
"factor": 1.145
},
{
"name": "impact",
"factor": 0.862
},
{
"name": "ink free",
"factor": 1.048
},
{
"name": "leelawadee ui semilight",
"factor": 1.061
},
{
"name": "lucida console",
"factor": 1.131
},
{
"name": "lucida sans unicode",
"factor": 1.014
},
{
"name": "malgun gothic",
"factor": 1.128
},
{
"name": "malgun gothic semilight",
"factor": 1.051
},
{
"name": "marlett",
"factor": 0.855
},
{
"name": "microsoft himalaya",
"factor": 1.022
},
{
"name": "microsoft jhenghei",
"factor": 1.061
},
{
"name": "microsoft jhenghei light",
"factor": 0.94
},
{
"name": "microsoft jhenghei ui",
"factor": 1.136
},
{
"name": "microsoft jhenghei ui light",
"factor": 0.826
},
{
"name": "microsoft new tai lue",
"factor": 0.953
},
{
"name": "microsoft phagspa",
"factor": 0.988
},
{
"name": "microsoft sans serif",
"factor": 0.941
},
{
"name": "microsoft tai le",
"factor": 0.957
},
{
"name": "microsoft yahei",
"factor": 1.076
},
{
"name": "microsoft yahei light",
"factor": 1.043
},
{
"name": "microsoft yahei ui",
"factor": 1.076
},
{
"name": "microsoft yahei ui light",
"factor": 1.094
},
{
"name": "microsoft yi baiti",
"factor": 1.008
},
{
"name": "mingliu-extb",
"factor": 1.173
},
{
"name": "mingliu_hkscs-extb",
"factor": 1.101
},
{
"name": "mongolian baiti",
"factor": 1.056
},
{
"name": "ms gothic",
"factor": 1.119
},
{
"name": "ms pgothic",
"factor": 1.074
},
{
"name": "mv boli",
"factor": 0.932
},
{
"name": "nirmala ui",
"factor": 0.916
},
{
"name": "nirmala ui semilight",
"factor": 1.15
},
{
"name": "nsimsun",
"factor": 1.119
},
{
"name": "palatino linotype",
"factor": 1.067
},
{
"name": "pmingliu-extb",
"factor": 1.16
},
{
"name": "sans serif collection",
"factor": 0.854
},
{
"name": "segoe mdl2 assets",
"factor": 0.858
},
{
"name": "segoe print",
"factor": 1.033
},
{
"name": "segoe script",
"factor": 1.058
},
{
"name": "segoe ui",
"factor": 0.948
},
{
"name": "segoe ui black",
"factor": 1.148
},
{
"name": "segoe ui emoji",
"factor": 0.924
},
{
"name": "segoe ui historic",
"factor": 0.969
},
{
"name": "segoe ui semibold",
"factor": 0.965
},
{
"name": "segoe ui semilight",
"factor": 1.055
},
{
"name": "segoe ui symbol",
"factor": 0.972
},
{
"name": "segoe ui variable",
"factor": 0.907
},
{
"name": "simsun",
"factor": 1.113
},
{
"name": "simsun-extb",
"factor": 1.095
},
{
"name": "sitka banner",
"factor": 1.078
},
{
"name": "sitka display",
"factor": 0.838
},
{
"name": "sitka heading",
"factor": 0.843
},
{
"name": "sitka small",
"factor": 1.131
},
{
"name": "sitka subheading",
"factor": 0.942
},
{
"name": "sitka text",
"factor": 1.044
},
{
"name": "sylfaen",
"factor": 0.971
},
{
"name": "symbol",
"factor": 0.866
},
{
"name": "tahoma",
"factor": 0.962
},
{
"name": "times new roman",
"factor": 0.926
},
{
"name": "trebuchet ms",
"factor": 0.912
},
{
"name": "verdana",
"factor": 1.128
},
{
"name": "webdings",
"factor": 0.849
},
{
"name": "wingdings",
"factor": 0.864
},
{
"name": "wingdings 2",
"factor": 1.093
},
{
"name": "wingdings 3",
"factor": 0.861
},
{
"name": "yu gothic",
"factor": 1.069
},
{
"name": "yu gothic light",
"factor": 1.038
},
{
"name": "yu gothic medium",
"factor": 1.161
},
{
"name": "yu gothic ui",
"factor": 0.886
},
{
"name": "yu gothic ui light",
"factor": 1.093
},
{
"name": "yu gothic ui semibold",
"factor": 0.942
},
{
"name": "yu gothic ui semilight",
"factor": 0.923
}
],
"optional": [
{
"name": "aparajita",
"factor": 0.94
},
{
"name": "arabic typesetting",
"factor": 1.093
},
{
"name": "arial unicode ms",
"factor": 1.047
},
{
"name": "batang",
"factor": 0.911
},
{
"name": "calibri",
"factor": 0.934
},
{
"name": "century",
"factor": 1.078
},
{
"name": "century gothic",
"factor": 0.886
},
{
"name": "dengxian",
"factor": 0.846
},
{
"name": "dfkai-sb",
"factor": 0.946
},
{
"name": "dokchampa",
"factor": 1.162
},
{
"name": "estrangelo edessa",
"factor": 1.095
},
{
"name": "euphemia",
"factor": 1.117
},
{
"name": "fangsong",
"factor": 0.942
},
{
"name": "franklin gothic",
"factor": 0.942
},
{
"name": "gadugi",
"factor": 0.945
},
{
"name": "gautami",
"factor": 1.134
},
{
"name": "haettenschweiler",
"factor": 0.874
},
{
"name": "helv",
"factor": 0.923
},
{
"name": "iskoola pota",
"factor": 0.857
},
{
"name": "javanese text",
"factor": 1.083
},
{
"name": "kaiti",
"factor": 1.145
},
{
"name": "kalinga",
"factor": 0.953
},
{
"name": "kartika",
"factor": 0.882
},
{
"name": "khmer ui",
"factor": 1.137
},
{
"name": "kokila",
"factor": 0.919
},
{
"name": "lao ui",
"factor": 0.904
},
{
"name": "latha",
"factor": 0.839
},
{
"name": "leelawadee",
"factor": 0.982
},
{
"name": "leelawadee ui",
"factor": 0.992
},
{
"name": "levenim mt",
"factor": 1.054
},
{
"name": "mangal",
"factor": 1.154
},
{
"name": "meiryo",
"factor": 1.165
},
{
"name": "meiryo ui",
"factor": 1.081
},
{
"name": "microsoft uighur",
"factor": 0.969
},
{
"name": "monotype corsiva",
"factor": 0.939
},
{
"name": "ms mincho",
"factor": 1.112
},
{
"name": "ms outlook",
"factor": 0.921
},
{
"name": "ms pmincho",
"factor": 0.925
},
{
"name": "ms reference sans serif",
"factor": 0.858
},
{
"name": "ms reference specialty",
"factor": 0.958
},
{
"name": "ms ui gothic",
"factor": 1.097
},
{
"name": "mt extra",
"factor": 0.905
},
{
"name": "myanmar text",
"factor": 0.961
},
{
"name": "nyala",
"factor": 1.108
},
{
"name": "plantagenet cherokee",
"factor": 1.115
},
{
"name": "pmingliu",
"factor": 1.125
},
{
"name": "pristina",
"factor": 1.023
},
{
"name": "raavi",
"factor": 0.826
},
{
"name": "segoe fluent icons",
"factor": 1.049
},
{
"name": "segoe ui light",
"factor": 0.918
},
{
"name": "shonar bangla",
"factor": 1.141
},
{
"name": "shruti",
"factor": 1.172
},
{
"name": "simhei",
"factor": 1.141
},
{
"name": "simkai",
"factor": 1.1
},
{
"name": "small fonts",
"factor": 0.849
},
{
"name": "traditional arabic",
"factor": 0.893
},
{
"name": "tunga",
"factor": 0.825
},
{
"name": "urdu typesetting",
"factor": 0.869
},
{
"name": "utsaah",
"factor": 1.144
},
{
"name": "vani",
"factor": 1.078
},
{
"name": "vijaya",
"factor": 0.917
},
{
"name": "vrinda",
"factor": 0.985
},
{
"name": "yu mincho",
"factor": 0.867
}
]
}

View file

@ -1,17 +0,0 @@
{
"_meta": {
"name": "audio (joint: sample_rate, output_latency_ms, max_channel_count)",
"parents": [],
"child": "audio",
"source": "Curated from common Windows WASAPI/DirectSound configs on FF desktop"
},
"table": [
{"value": {"rate": 44100, "latency": 40, "channels": 2}, "prob": 0.20},
{"value": {"rate": 48000, "latency": 30, "channels": 2}, "prob": 0.25},
{"value": {"rate": 48000, "latency": 20, "channels": 2}, "prob": 0.15},
{"value": {"rate": 48000, "latency": 40, "channels": 6}, "prob": 0.08},
{"value": {"rate": 48000, "latency": 60, "channels": 2}, "prob": 0.12},
{"value": {"rate": 44100, "latency": 50, "channels": 2}, "prob": 0.10},
{"value": {"rate": 48000, "latency": 25, "channels": 2}, "prob": 0.10}
]
}

View file

@ -1,14 +0,0 @@
{
"_meta": {
"name": "Independent marginal priors (no parents)",
"source": "StatCounter GlobalStats + Firefox defaults + community data 2025-2026",
"note": "Codec prefs (av1_enabled, webm_encoder_enabled, hw_video_decoding, wmf_enabled, ffvpx_enabled) moved to cpt_codec_given_class.json — they correlate with GPU class via Firefox version / user-tier distribution."
},
"dark_theme": {
"_note": "0=light, 1=dark. StatCounter ~40% users report dark theme preference on desktop.",
"table": [
{"value": 0, "prob": 0.60},
{"value": 1, "prob": 0.40}
]
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,273 +0,0 @@
"""Public dataclass surface for fpforge."""
from __future__ import annotations
from dataclasses import dataclass, field, replace as _dc_replace
from typing import Any, Dict, List, Optional
from ._sampler import sample as _sample_raw
@dataclass(frozen=True)
class GPUProfile:
vendor: str
renderer: str
class_tier: str # "low_end" | "mid_range" | "high_end" | "integrated_old" | "integrated_modern"
@dataclass(frozen=True)
class ScreenProfile:
width: int
height: int
avail_width: int
avail_height: int
dpr: float
tier: str
@dataclass(frozen=True)
class HardwareProfile:
concurrency: int
storage_quota_mb: int
@dataclass(frozen=True)
class AudioProfile:
sample_rate: int
output_latency_ms: int
max_channel_count: int
@dataclass(frozen=True)
class CodecProfile:
av1_enabled: bool
webm_encoder_enabled: bool
mediasource_webm: bool
mediasource_mp4: bool
webspeech_synth: bool
@dataclass(frozen=True)
class WebGLProfile:
msaa_samples: int
# ──────────────────────────────────────────────────────────────────────
# Pin map: flat dotted-path -> value. Set via `pin=` on generate_profile.
#
# Supported keys:
# "gpu.vendor", "gpu.renderer", "gpu.class_tier"
# "screen.width", "screen.height", "screen.avail_width",
# "screen.avail_height", "screen.dpr", "screen.tier"
# "hardware.concurrency", "hardware.storage_quota_mb"
# "audio.sample_rate", "audio.output_latency_ms",
# "audio.max_channel_count"
# "codec.av1_enabled", "codec.webm_encoder_enabled",
# "codec.mediasource_webm", "codec.mediasource_mp4",
# "codec.webspeech_synth"
# "webgl.msaa_samples"
# "fonts" (replaces the whole list)
# "dark_theme"
# ──────────────────────────────────────────────────────────────────────
_PIN_GROUPS = {
"gpu": {"vendor", "renderer", "class_tier"},
"screen": {"width", "height", "avail_width", "avail_height", "dpr", "tier"},
"hardware": {"concurrency", "storage_quota_mb"},
"audio": {"sample_rate", "output_latency_ms", "max_channel_count"},
"codec": {
"av1_enabled", "webm_encoder_enabled",
"mediasource_webm", "mediasource_mp4", "webspeech_synth",
},
"webgl": {"msaa_samples"},
}
_PIN_TOP = {"fonts", "dark_theme"}
def _validate_pin_key(key: str) -> None:
if key in _PIN_TOP:
return
if "." not in key:
raise ValueError(
f"pin key {key!r} is not valid. "
f"Use 'group.field' (e.g. 'screen.width') or one of {sorted(_PIN_TOP)}."
)
group, field_name = key.split(".", 1)
if group not in _PIN_GROUPS:
raise ValueError(
f"pin key {key!r}: unknown group {group!r}. "
f"Known groups: {sorted(_PIN_GROUPS)}."
)
if field_name not in _PIN_GROUPS[group]:
raise ValueError(
f"pin key {key!r}: unknown field {field_name!r} in group {group!r}. "
f"Known fields: {sorted(_PIN_GROUPS[group])}."
)
@dataclass(frozen=True)
class Profile:
"""Coherent browser fingerprint profile sampled from a single integer seed.
Use `generate_profile(seed)` to build one. Pin specific values at build
time with `generate_profile(seed, pin={"screen.width": 2560, ...})`.
"""
seed: int
gpu: GPUProfile
screen: ScreenProfile
hardware: HardwareProfile
audio: AudioProfile
codec: CodecProfile
webgl: WebGLProfile
fonts: List[str]
dark_theme: bool
# Bayesian browsing-history: list of {name, category, cookie_profile}
# dicts sampled from data/browsing_pool.json with per-class CPT. Used
# by _recaptcha_seed.py to build a coherent cookie pre-seed when the
# caller opts in via Stealthfox(prep_recaptcha=True).
browsing_history: List[Dict[str, str]] = field(default_factory=list)
_raw: Dict[str, Any] = field(default_factory=dict, repr=False, compare=False)
def to_prefs_dict(self) -> Dict[str, Any]:
"""Return the flat dict of raw sampler fields, as produced by the
underlying Bayesian sampler. Stable across releases for a given seed."""
return dict(self._raw)
# Mapping from flat pin key -> raw sampler dict key, so `to_prefs_dict()`
# and `invisible_playwright.prefs.translate_profile_to_prefs` observe the pinned value.
_PIN_TO_RAW = {
"gpu.vendor": "webgl_vendor",
"gpu.renderer": "webgl_renderer",
"gpu.class_tier": "gpu_class",
"screen.width": "screen_w",
"screen.height": "screen_h",
"screen.avail_width": "screen_avail_w",
"screen.avail_height": "screen_avail_h",
"screen.dpr": "dpr",
"screen.tier": "screen_tier",
"hardware.concurrency": "hw_concurrency",
"hardware.storage_quota_mb": "storage_quota_mb",
"audio.sample_rate": "audio_sample_rate",
"audio.output_latency_ms": "audio_output_latency_ms",
"audio.max_channel_count": "audio_max_channel_count",
"codec.av1_enabled": "av1_enabled",
"codec.webm_encoder_enabled": "webm_encoder_enabled",
"codec.mediasource_webm": "mediasource_webm",
"codec.mediasource_mp4": "mediasource_mp4",
"codec.webspeech_synth": "webspeech_synth",
"webgl.msaa_samples": "msaa_samples",
"dark_theme": "dark_theme",
# "fonts" is a list — handled specially (joined into font_whitelist).
}
def _apply_pins_to_raw(raw: Dict[str, Any], pin: Dict[str, Any]) -> Dict[str, Any]:
"""Return a copy of `raw` with the pinned sampler-level fields updated."""
out = dict(raw)
for key, value in pin.items():
if key == "fonts":
if not isinstance(value, (list, tuple)):
raise TypeError("pin 'fonts' must be a list/tuple of strings")
out["font_whitelist"] = ",".join(value)
continue
raw_key = _PIN_TO_RAW.get(key)
if raw_key is None:
# Shouldn't happen after validation, but guard anyway.
continue
out[raw_key] = value
return out
def generate_profile(
seed: int,
pin: Optional[Dict[str, Any]] = None,
fixed_gpu_class: Optional[str] = None,
) -> Profile:
"""Return a deterministic Profile for the given integer seed.
pin: optional dict of dotted-path keys (e.g. "screen.width", "gpu.renderer")
to values that are FORCED in the resulting profile. All other fields
are still sampled from the Bayesian network based on `seed`, so the
same seed + same pin map always yields the same profile.
Example force a specific GPU and screen while letting everything
else vary with the seed (via the public invisible_playwright API):
from invisible_playwright import InvisiblePlaywright
with InvisiblePlaywright(
seed=42,
pin={
"gpu.renderer": "ANGLE (NVIDIA, NVIDIA GeForce RTX 4090 Direct3D11)",
"gpu.vendor": "Google Inc. (NVIDIA)",
"gpu.class_tier": "high_end",
"screen.width": 2560,
"screen.height": 1440,
},
) as browser:
...
Warning: pinning breaks Bayesian coherence across the pinned fields
(if you pin a high-end GPU but leave screen unpinned, you may get a
1080p screen that would be unusual for that GPU class). Pin related
fields together when coherence matters.
Supported keys: see the module-level _PIN_GROUPS / _PIN_TOP tables
or run `help(generate_profile)` after import.
"""
if pin:
for key in pin:
_validate_pin_key(key)
# fixed_gpu_class re-conditions the whole bundle on a chosen class (used so the
# bundle stays coherent with the validated WebGL persona we expose on Windows/mac).
# An explicit gpu.class_tier pin still wins.
eff_class = (pin or {}).get("gpu.class_tier") or fixed_gpu_class
raw = _sample_raw(int(seed), fixed_gpu_class=eff_class)
if pin:
raw = _apply_pins_to_raw(raw, pin)
# Font whitelist is stored as a comma-separated string in raw; split it.
font_wl = raw.get("font_whitelist", "")
if isinstance(font_wl, str):
fonts = [f.strip() for f in font_wl.split(",") if f.strip()]
else:
fonts = list(font_wl) if font_wl else []
return Profile(
seed=int(raw["stealth_seed"]),
gpu=GPUProfile(
vendor=raw["webgl_vendor"],
renderer=raw["webgl_renderer"],
class_tier=raw["gpu_class"],
),
screen=ScreenProfile(
width=int(raw["screen_w"]),
height=int(raw["screen_h"]),
avail_width=int(raw["screen_avail_w"]),
avail_height=int(raw["screen_avail_h"]),
dpr=float(raw["dpr"]),
tier=str(raw.get("screen_tier", "")),
),
hardware=HardwareProfile(
concurrency=int(raw["hw_concurrency"]),
storage_quota_mb=int(raw["storage_quota_mb"]),
),
audio=AudioProfile(
sample_rate=int(raw["audio_sample_rate"]),
output_latency_ms=int(raw["audio_output_latency_ms"]),
max_channel_count=int(raw["audio_max_channel_count"]),
),
codec=CodecProfile(
av1_enabled=bool(raw["av1_enabled"]),
webm_encoder_enabled=bool(raw["webm_encoder_enabled"]),
mediasource_webm=bool(raw["mediasource_webm"]),
mediasource_mp4=bool(raw["mediasource_mp4"]),
webspeech_synth=bool(raw["webspeech_synth"]),
),
webgl=WebGLProfile(msaa_samples=int(raw["msaa_samples"])),
fonts=fonts,
dark_theme=bool(raw["dark_theme"]),
browsing_history=list(raw.get("browsing_history") or []),
_raw=raw,
)

View file

@ -1,218 +1,4 @@
"""Resolve the session timezone from the egress IP (``timezone="auto"``).
Approach B: discover the egress IP with one HTTP request routed *through the
proxy* when one is set, otherwise a direct request that sees the host's own
public IP then map IP IANA timezone with an offline mmdb
(``daijro/geoip-all-in-one``, downloaded + cached by ``download.py``).
Precedence (see ``resolve_session_timezone``):
explicit IANA unchanged explicit always wins
"" / "auto" egress ALWAYS resolve. With a proxy, from the proxy
egress IP; without a proxy, from the host's
own public IP. This is the default.
On failure:
with a proxy raise a foreign proxy paired with the host TZ is
the precise ``timezone_mismatch`` signal, so
we fail loudly rather than fall back silently.
without a proxy "" (host) the host TZ is a safe default, so a transient
lookup failure must not break the launch.
"""
from __future__ import annotations
import ipaddress
from typing import Any, Dict, NamedTuple, Optional
from urllib.parse import quote
import requests
class GeoTimezoneError(RuntimeError):
"""Raised when ``timezone="auto"`` cannot resolve a valid IANA zone."""
# Plain-text IP echo endpoints (each returns just the caller's public IP).
_IP_ECHO_ENDPOINTS = (
"https://api.ipify.org",
"https://icanhazip.com",
"https://checkip.amazonaws.com",
)
_SOCKS_SCHEMES = ("socks5://", "socks4://", "socks://")
def _proxy_is_set(proxy: Optional[Dict[str, str]]) -> bool:
if not proxy:
return False
server = (proxy.get("server") or "").strip()
return bool(server) and server.lower() != "direct://"
def _proxies_for_requests(proxy: Dict[str, str]) -> Dict[str, str]:
"""Translate our proxy dict into a ``requests`` proxies mapping.
SOCKS5 uses the ``socks5h`` scheme so DNS is resolved proxy-side (matches
``network.proxy.socks_remote_dns=True`` in the Firefox path). HTTP/HTTPS
pass through unchanged. Credentials are URL-encoded.
"""
server = (proxy.get("server") or "").strip()
low = server.lower()
if low.startswith("socks5://") or low.startswith("socks://"):
scheme = "socks5h"
elif low.startswith("socks4://"):
scheme = "socks4"
elif low.startswith("https://"):
scheme = "https"
else:
scheme = "http"
host_port = server.split("://", 1)[1] if "://" in server else server
user = proxy.get("username") or ""
pwd = proxy.get("password") or ""
if user:
auth = f"{quote(user, safe='')}:{quote(pwd, safe='')}@"
else:
auth = ""
url = f"{scheme}://{auth}{host_port}"
return {"http": url, "https": url}
def discover_egress_ip(
proxy: Optional[Dict[str, str]] = None, *, timeout: float = 10.0
) -> str:
"""Return the public egress IP.
Routes the request through ``proxy`` when given (SOCKS support requires
``requests[socks]`` / PySocks); with ``proxy=None`` it makes a direct
request that sees the host's own public IP. Tries each echo endpoint in
turn; raises :class:`GeoTimezoneError` if none return a valid IP.
"""
proxies = _proxies_for_requests(proxy) if proxy else None
last_err: Optional[Exception] = None
for url in _IP_ECHO_ENDPOINTS:
try:
resp = requests.get(url, proxies=proxies, timeout=timeout)
resp.raise_for_status()
ip = resp.text.strip()
ipaddress.ip_address(ip) # validate (raises ValueError if not an IP)
return ip
except Exception as exc: # noqa: BLE001 - try the next endpoint
last_err = exc
continue
raise GeoTimezoneError(
f"could not discover the proxy egress IP via {len(_IP_ECHO_ENDPOINTS)} "
f"endpoints (last error: {last_err!r}). For SOCKS proxies make sure "
f"requests[socks] / PySocks is installed."
)
def ip_to_timezone(ip: str, mmdb_path: Any) -> str:
"""Map ``ip`` to its IANA timezone using the offline mmdb.
Reads the standard MaxMind ``location.time_zone`` field and validates it
against the system tz database. Raises :class:`GeoTimezoneError` if the IP
is absent from the DB or the zone is missing / not a valid IANA name.
"""
import maxminddb
with maxminddb.open_database(str(mmdb_path)) as reader:
record = reader.get(ip)
if not record:
raise GeoTimezoneError(f"egress IP {ip} not present in the geoip database")
tz = ((record.get("location") or {}) if isinstance(record, dict) else {}).get(
"time_zone"
)
if not tz:
raise GeoTimezoneError(f"no timezone for egress IP {ip} in the geoip database")
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
try:
ZoneInfo(tz)
except (ZoneInfoNotFoundError, ValueError) as exc:
raise GeoTimezoneError(
f"geoip returned an invalid IANA zone {tz!r} for {ip}: {exc}"
) from exc
return tz
class SessionGeo(NamedTuple):
"""Geo facts resolved once per session from a single egress round-trip.
``timezone`` follows the precedence in the module docstring.
``egress_ip`` is the proxy egress IP (the IP the *outside world* sees) when
a proxy is set, else ``None`` it feeds the WebRTC srflx override, which is
only meaningful behind a proxy (a direct connection's real STUN already
reports the truthful public IP, so we leave it alone).
"""
timezone: str
egress_ip: Optional[str]
def prepare_session_geo(
timezone: str, proxy: Optional[Dict[str, str]]
) -> SessionGeo:
"""Resolve the session timezone AND the proxy egress IP in ONE round-trip.
The egress IP is discovered once and reused for both the timezone mapping
(when ``timezone`` is ``""``/``"auto"``) and the WebRTC public-IP override.
Timezone precedence is identical to :func:`resolve_session_timezone`; the
egress IP is best-effort for the WebRTC side (a discovery failure that the
timezone path doesn't need won't break the launch but if the timezone
path *does* need it behind a proxy, that path still fails loudly).
"""
from .download import ensure_geoip_mmdb
tz = (timezone or "").strip()
proxy_set = _proxy_is_set(proxy)
# One discovery, reused below. Behind a proxy we always want the egress IP
# (for WebRTC) regardless of the timezone setting.
egress_ip: Optional[str] = None
egress_err: Optional[Exception] = None
if proxy_set:
try:
egress_ip = discover_egress_ip(proxy)
except Exception as exc: # noqa: BLE001
egress_err = exc
# Timezone resolution — same precedence as resolve_session_timezone.
if tz and tz.lower() != "auto":
return SessionGeo(tz, egress_ip) # explicit IANA wins
try:
ip = egress_ip if proxy_set else discover_egress_ip(None)
if ip is None: # proxy set but discovery failed above
raise egress_err or GeoTimezoneError("egress IP discovery failed")
return SessionGeo(ip_to_timezone(ip, ensure_geoip_mmdb()), egress_ip)
except Exception:
if proxy_set:
raise # fail-early behind a proxy (timezone_mismatch trap)
return SessionGeo("", None) # no proxy: host TZ is a safe fallback
def resolve_session_timezone(
timezone: str, proxy: Optional[Dict[str, str]]
) -> str:
"""Map the user's ``timezone`` setting to a concrete IANA zone (or ``""``).
Timezone-only path (no WebRTC side effects): an explicit IANA zone wins and
triggers NO network call; ``""``/``"auto"`` resolve from the egress IP. The
launch path uses :func:`prepare_session_geo` instead (which additionally
returns the egress IP for WebRTC); this standalone resolver is kept for
third-party integrations that only want the zone. See the module docstring
for the precedence table.
"""
tz = (timezone or "").strip()
if tz and tz.lower() != "auto":
return tz # explicit IANA wins — no egress lookup
from .download import ensure_geoip_mmdb
proxy_set = _proxy_is_set(proxy)
try:
ip = discover_egress_ip(proxy if proxy_set else None)
return ip_to_timezone(ip, ensure_geoip_mmdb())
except Exception:
if proxy_set:
raise # fail-early behind a proxy (timezone_mismatch trap)
return "" # no proxy: host TZ is a safe fallback
"""Backward-compat shim — spostato in invisible_core._geo (alias completo)."""
import sys as _sys
from invisible_core import _geo as _mod
_sys.modules[__name__] = _mod

View file

@ -1,178 +1,4 @@
"""Invisible-but-headed browser windows.
Playwright's ``headless=True`` flips Firefox onto a different code path —
no widget tree, software-only rendering, distinct timing and anti-bot
systems can spot the divergence. Running the browser *headed* but hidden
gives us the real rendering pipeline while keeping the windows off screen.
Two mechanisms, by platform:
- **Windows & macOS**: the patched binary cloaks its OWN chrome windows
when ``zoom.stealth.cloak_windows`` is set ``DWMWA_CLOAK`` (Windows)
/ ``NSWindow`` alpha-0 + pinned occlusion-ignore (macOS). The window
renders on the real GPU but never appears on screen, in the taskbar or
the Dock. The launcher injects the pref; nothing host-side is spawned.
- **Linux**: spawns its own ``Xvfb`` instance and points ``DISPLAY`` at
it (X11/Wayland have no per-window cloak that keeps the GPU rendering).
"""
from __future__ import annotations
import os
import subprocess
import sys
import time
from typing import Optional
# Inherited from WSLg / GNOME / etc. these env vars make Firefox prefer a
# Wayland compositor over the X11 DISPLAY we set, so the window leaks onto
# the real desktop. Strip them all before starting.
_WAYLAND_LEAK_VARS = (
"WAYLAND_DISPLAY",
"XDG_RUNTIME_DIR",
"XDG_SESSION_TYPE",
"PULSE_SERVER",
"WSL2_GUI_APPS_ENABLED",
)
class _LinuxVirtualDisplay:
"""Standalone Xvfb instance owned by this InvisiblePlaywright session."""
def __init__(self, width: int = 1920, height: int = 1080) -> None:
self._geometry = f"{width}x{height}x24"
self._proc: Optional[subprocess.Popen] = None
self._display: Optional[str] = None
self._saved_env: dict[str, Optional[str]] = {}
def start(self) -> None:
if not _binary_on_path("Xvfb"):
raise RuntimeError(
"invisible_playwright headless=True requires Xvfb. "
"Install it: sudo apt install xvfb"
)
# Retry: when many workers start in parallel they can pick the same
# display number before any has created its lockfile. Xvfb on the
# losing side exits immediately — try again with a fresh number.
last_err: Optional[Exception] = None
for _ in range(10):
display = self._pick_display()
try:
self._spawn(display)
self._wait_until_ready(display)
self._display = display
self._apply_env(display)
return
except RuntimeError as e:
last_err = e
if self._proc is not None and self._proc.poll() is None:
self._proc.kill()
self._proc = None
raise RuntimeError(f"Xvfb failed to start after 10 attempts: {last_err}")
def _spawn(self, display: str) -> None:
self._proc = subprocess.Popen(
[
"Xvfb", display,
"-screen", "0", self._geometry,
"+extension", "GLX",
"+extension", "RENDER",
"-nolisten", "unix",
"-listen", "tcp",
"-ac",
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
def _pick_display(self) -> str:
for n in range(99, 400):
if not os.path.exists(f"/tmp/.X{n}-lock"):
return f":{n}"
raise RuntimeError("no free X display number in :99:399")
def _wait_until_ready(self, display: str) -> None:
# We start Xvfb with -nolisten unix → no /tmp/.X11-unix socket appears.
# Xvfb creates /tmp/.X{n}-lock immediately though — wait for that.
lockfile = f"/tmp/.X{display[1:]}-lock"
deadline = time.monotonic() + 3.0
assert self._proc is not None
while time.monotonic() < deadline:
if self._proc.poll() is not None:
raise RuntimeError(f"Xvfb {display} exited immediately")
if os.path.exists(lockfile):
return
time.sleep(0.02)
raise RuntimeError(f"Xvfb {display} did not become ready in 3s")
def _apply_env(self, display: str) -> None:
keys = ("DISPLAY", "MOZ_ENABLE_WAYLAND", "GDK_BACKEND") + _WAYLAND_LEAK_VARS
for k in keys:
self._saved_env[k] = os.environ.get(k)
for k in _WAYLAND_LEAK_VARS:
os.environ.pop(k, None)
os.environ["DISPLAY"] = display
os.environ["MOZ_ENABLE_WAYLAND"] = "0"
os.environ["GDK_BACKEND"] = "x11"
def stop(self) -> None:
for k, v in self._saved_env.items():
if v is None:
os.environ.pop(k, None)
else:
os.environ[k] = v
self._saved_env.clear()
if self._proc is not None and self._proc.poll() is None:
self._proc.terminate()
try:
self._proc.wait(timeout=3)
except subprocess.TimeoutExpired:
self._proc.kill()
self._proc.wait(timeout=2)
self._proc = None
self._display = None
# Windows & macOS: the patched Firefox cloaks its own chrome windows when this
# pref is set (DWMWA_CLOAK / NSWindow alpha-0 + pinned occlusion-ignore), so the
# window renders on the real GPU but never shows on screen / in the taskbar or
# Dock. window_occlusion_tracking is disabled so a hidden window keeps painting.
CLOAK_PREFS = {
"zoom.stealth.cloak_windows": True,
"widget.windows.window_occlusion_tracking.enabled": False,
}
def cloak_prefs() -> dict:
"""Prefs that make the patched binary self-cloak its chrome windows.
Used on Windows & macOS, where hiding is done inside the binary rather than
with a host-side virtual display.
"""
return dict(CLOAK_PREFS)
def make_virtual_display():
"""Return a start()/stop()-able virtual display, or ``None`` when the
platform hides windows via the in-binary cloak pref instead.
- Linux: a fresh ``Xvfb`` (the launcher start()s/stop()s it).
- Windows / macOS: ``None`` the binary self-cloaks via ``cloak_prefs()``,
injected by the launcher; nothing host-side needs spawning.
"""
if sys.platform.startswith("linux"):
return _LinuxVirtualDisplay()
if sys.platform in ("win32", "darwin"):
return None
raise RuntimeError(
f"invisible_playwright supports Windows, macOS and Linux "
f"(got {sys.platform!r})"
)
def _binary_on_path(name: str) -> bool:
import shutil
return shutil.which(name) is not None
"""Backward-compat shim — spostato in invisible_core._headless (alias completo)."""
import sys as _sys
from invisible_core import _headless as _mod
_sys.modules[__name__] = _mod

View file

@ -1,56 +1,4 @@
"""Proxy translation shared by sync and async launchers.
SOCKS proxies are driven entirely by the patched Firefox prefs (the
``nsProtocolProxyService`` patch reads ``network.proxy.socks_username``
and ``socks_password``). HTTP/HTTPS proxies go through Playwright's own
``proxy=`` kwarg so it can negotiate Basic auth.
"""
from __future__ import annotations
from typing import Any, Dict, Optional
_SOCKS_SCHEMES = ("socks5://", "socks4://", "socks://")
def configure_proxy(
proxy: Optional[Dict[str, str]],
prefs: Dict[str, Any],
) -> Optional[Dict[str, str]]:
"""Mutate ``prefs`` for SOCKS auth; return what to pass to Playwright.
* ``None`` proxy returns ``None``.
* SOCKS proxy writes the auth prefs and returns ``None`` (Playwright
gets nothing; Firefox does the rest).
* HTTP / HTTPS proxy returns the dict unchanged for Playwright.
"""
if not proxy:
return None
server = (proxy.get("server") or "").strip()
if not server or server.lower() == "direct://":
return None
if not _is_socks_scheme(server):
return proxy
host_port = _strip_scheme(server)
if ":" not in host_port:
return None # malformed, drop silently
host, port_str = host_port.rsplit(":", 1)
prefs["network.proxy.type"] = 1
prefs["network.proxy.socks"] = host
prefs["network.proxy.socks_port"] = int(port_str)
prefs["network.proxy.socks_version"] = 4 if server.lower().startswith("socks4://") else 5
prefs["network.proxy.socks_username"] = proxy.get("username") or ""
prefs["network.proxy.socks_password"] = proxy.get("password") or ""
prefs["network.proxy.socks_remote_dns"] = True
return None
def _is_socks_scheme(server: str) -> bool:
return server.lower().startswith(_SOCKS_SCHEMES)
def _strip_scheme(server: str) -> str:
return server.split("://", 1)[1] if "://" in server else server
"""Backward-compat shim — spostato in invisible_core._proxy (alias completo)."""
import sys as _sys
from invisible_core import _proxy as _mod
_sys.modules[__name__] = _mod

View file

@ -1,163 +1,4 @@
"""Empirically-calibrated WebGL GPU personas for Windows ANGLE D3D11.
We expose a FALSE GPU (this is a multi-user tool never leak each host's real GPU),
chosen deterministically per seed from a small set of renderer-string "buckets" that
Firefox's SanitizeRenderer emits and that FP Pro's tampering_ml scores as CLEAN.
## What actually gates a persona (calibrated 2026-06-14, supersedes the old theory)
The blocker is NOT anti_detect and NOT a "render-vs-renderer" check. It is FP Pro's
**tampering_ml** (gate <=0.5), a holistic ML coherence score. We reverse-engineered its
GPU sensitivity with single-variable A/Bs on demo.fingerprint.com (deterministic per
(seed, renderer, IP); tools in tests/_gpu_isolate.py / _gpu_landscape.py / _gpu_sweep.py /
_gpu_sweep2.py / _gpu_persona_pure.py). Findings:
1. tampering_ml = f(renderer STRING, seed baseline = canvas/audio). The renderer string
carries a STABLE per-bucket penalty; the seed sets the floor it adds to.
2. gpu_class is IRRELEVANT to tampering_ml (nv_980 scored identically on mid_range /
high_end / premium / workstation). So pairing a fake GPU with a "matching" hardware
tier does NOT help the score (we still set a coherent class see gpu_class below
for OTHER detectors that cross-check cores/screen, just not for this).
3. It is NOT render-consistency: a cross-vendor AMD string is CLEAN on our Intel-Arc
host. So the real silicon's pixels are not the dominant signal; falsifying to a
different vendor works IF the string is one FP Pro scores low.
Sweep over all 10 Windows SanitizeRenderer buckets x 10 seeds (clean = tml<=0.5 AND not
anti_detect), on our Intel Arc A750 host:
- amd_r9 (Radeon R9 200 Series) ...... 10/10 clean, max tml 0.346 <- SHIP
- intel_arc (Arc A750) ............... 10/10 clean, max tml 0.377 <- SHIP
- amd_hd5850 ......................... 9/10 (fails the hardest seed)
- amd_hd3200 / intel_hd .............. 6/10 (seed-dependent, risky)
- intel_hd400 ........................ 3/10
- ALL NVIDIA (8800/480/980) .......... 0/10 (penalized everywhere, ~0.7-0.99)
- intel_945 (ancient Intel) .......... 0/10
So only TWO buckets are robustly clean across profiles. We ship exactly those, weighted
to real-world prevalence ("Radeon R9 200 Series" is the bucket for ALL modern AMD = a big
real slice; "Arc A750" covers Intel discrete = rarer). Cross-vendor, so the fleet is not a
single-GPU cluster. More names require lowering the seed floor first (see CAVEAT 2).
## ⚠️ CAVEATS
1. HOST-INDEPENDENCE NOT PROVEN. Everything above was measured on ONE host (Intel Arc
A750). The host's real render is embedded in the seed baseline, so the clean-bucket set
*might* be host-dependent (on a real NVIDIA host, maybe nv_980 is clean and amd_r9 is
not). This MUST be validated on a non-Arc machine before trusting it fleet-wide; if it
turns out host-dependent, add a pre-launch host-GPU-class probe and pick a bucket per
detected class. Until then: safe for Arc hosts (incl. the dev's), unvalidated elsewhere.
2. DIVERSITY CEILING = 2 names because "hard" seeds (high canvas/audio floor, e.g. seed 4
~0.35) only stay clean on the 2 best buckets. Lowering that floor (an fpforge CPT fix
candidate: 8-channel audio + 1TB storage emitted on a mid_range profile) would unlock
amd_hd5850 / intel_hd for more seeds => up to ~5 names. Follow-up, not done yet.
## Load-bearing format requirements (unchanged, still true)
- renderer MUST end ", D3D11)" (full ANGLE wire format) or SanitizeRenderer returns
"Generic Renderer" (a tell). The C++ passes our string through SanitizeRenderer, which
buckets "AMD Radeon R9 200 Series" -> "Radeon R9 200 Series" and "Arc A750" -> itself.
- the forced extension list MUST be the EXACT NATIVE ORDER getSupportedExtensions returns.
The set+order is fixed by Firefox+ANGLE on D3D11 FL11_0 (VENDOR-INDEPENDENT verified
via 20-agent source study), so ONE list is correct for both personas. A reorder is caught
(tampering_ml 0.34 -> 0.84). The lists below are the verbatim native-order Arc capture.
Calibration data + sweep tooling live in the local workbench (not shipped).
"""
from __future__ import annotations
import sys
from typing import Dict, List, Optional
# Vendor-independent ext lists (native order, Arc host capture). Identical for every persona
# because the set+order is fixed by Firefox+ANGLE on D3D11 FL11_0, not by the GPU vendor.
_EXT1 = (
"ANGLE_instanced_arrays,EXT_blend_minmax,EXT_color_buffer_half_float,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_s3tc,WEBGL_compressed_texture_s3tc_srgb,"
"WEBGL_debug_renderer_info,WEBGL_debug_shaders,WEBGL_depth_texture,WEBGL_draw_buffers,"
"WEBGL_lose_context,WEBGL_provoking_vertex"
)
_EXT2 = (
"EXT_color_buffer_float,EXT_float_blend,EXT_texture_compression_bptc,"
"EXT_texture_compression_rgtc,EXT_texture_filter_anisotropic,OES_draw_buffers_indexed,"
"OES_texture_float_linear,OVR_multiview2,WEBGL_compressed_texture_s3tc,"
"WEBGL_compressed_texture_s3tc_srgb,WEBGL_debug_renderer_info,WEBGL_debug_shaders,"
"WEBGL_lose_context,WEBGL_provoking_vertex"
)
def _p(key, renderer, vendor, gpu_class, weight):
return {"key": key, "renderer": renderer, "vendor": vendor,
"gpu_class": gpu_class, "weight": weight, "ext1": _EXT1, "ext2": _EXT2}
# Only the two robustly-clean Windows buckets (calibration sweep 2026-06-14). Both discrete,
# so gpu_class=mid_range keeps cores/screen coherent with the declared GPU for OTHER detectors
# (gpu_class does NOT affect tampering_ml). Weights ~ real-world prevalence of the BUCKET:
# "Radeon R9 200 Series" represents ALL modern AMD (large real slice); "Arc A750" = Intel
# discrete (rarer). Cross-vendor => the fleet is not a single-GPU cluster.
_PERSONAS: List[Dict] = [
_p("amd_radeon_r9", "ANGLE (AMD, AMD Radeon R9 200 Series Direct3D11 vs_5_0 ps_5_0, D3D11)",
"Google Inc. (AMD)", "mid_range", 70), # -> bucket "Radeon R9 200 Series"; tml 0.03-0.35
_p("intel_arc_a750", "ANGLE (Intel, Intel(R) Arc(TM) A750 Graphics Direct3D11 vs_5_0 ps_5_0, D3D11)",
"Google Inc. (Intel)", "mid_range", 30), # -> bucket "Intel(R) Arc(TM) A750 Graphics"; tml 0.02-0.38
]
_TOTAL_W = sum(p["weight"] for p in _PERSONAS)
# ENABLED: we falsify the GPU on Windows/mac. Validated clean on an Intel Arc host (see the
# HOST-INDEPENDENCE caveat in the module docstring — unvalidated on non-Arc hosts). On Linux
# select_persona returns None: there prefs.py spoofs profile.gpu.renderer directly.
_ENABLED = True
def select_persona(seed: int) -> Optional[Dict]:
"""Deterministic, prevalence-weighted persona for this seed (None on Linux).
Same seed -> same persona (fppro_consistency: identity stable per seed). Different seeds
spread across the persona mix by weight. None on Linux (the sampled profile.gpu.renderer
is spoofed directly there).
"""
if not _ENABLED or sys.platform.startswith("linux") or not _PERSONAS:
return None
h = (int(seed) * 2654435761) % _TOTAL_W
cum = 0
for p in _PERSONAS:
cum += p["weight"]
if h < cum:
return p
return _PERSONAS[-1]
def forced_gpu_class(seed: int) -> Optional[str]:
"""The gpu_class the forge conditions the WHOLE bundle on (== the selected persona's class),
so cores/screen/fonts stay coherent with the GPU we expose. Does NOT affect FP Pro
tampering_ml (proven) but matters for detectors that cross-check hardware tier. None on Linux."""
p = select_persona(seed)
return p["gpu_class"] if p else None
# ── Render-noise seed pool (canvas/WebGL gamma) ──────────────────────────────
# zoom.stealth.fpp.hw_seed drives the per-seed canvas2D + WebGL readPixels gamma
# LUT in C++. The render-image HASH it produces is the DOMINANT FP Pro tampering_ml
# driver (proven 2026-06-14: holding a fixed profile and varying ONLY hw_seed moved
# tml 0.25->0.75). The monotonic gamma preserves the GPU's render structure, so some
# hw_seeds yield a "suspicious" render hash. We therefore DECOUPLE the render-noise
# seed from the identity seed and pick from a calibrated pool of hw_seeds that score
# CLEAN even on the hardest attribute profile (sweep 1..30 vs the worst seed: these
# 14 all gave tml<=0.285). Diversity is preserved (14 distinct render hashes spread
# across the population — real GPUs cluster to few canvas hashes anyway); identity
# stays per-seed (the rest of the fingerprint differs). Same seed -> same render seed
# (fppro_consistency holds).
# CAVEAT: the render hash = f(host GPU render, gamma), so this pool is calibrated on
# the Intel-Arc host. On other GPUs the clean set may differ (host-independence open,
# same as the personas) — Option B (substitution = GPU-independent render hash) would
# remove that dependency. Validate per-host or move to B before trusting fleet-wide.
CLEAN_RENDER_SEEDS = [19, 10, 28, 24, 23, 16, 11, 30, 17, 22, 3, 9, 12, 26]
def render_noise_seed(seed: int) -> int:
"""Deterministic clean render-noise seed for hw_seed (decoupled from identity).
Maps the identity seed into CLEAN_RENDER_SEEDS so every session gets a calibrated
clean canvas/WebGL render hash while keeping per-user diversity. Stable per seed."""
return CLEAN_RENDER_SEEDS[(int(seed) * 2654435761) % len(CLEAN_RENDER_SEEDS)]
"""Backward-compat shim — spostato in invisible_core._webgl_personas (alias completo)."""
import sys as _sys
from invisible_core import _webgl_personas as _mod
_sys.modules[__name__] = _mod

View file

@ -48,7 +48,7 @@ class InvisiblePlaywright:
proxy: Optional[Dict[str, str]] = None,
extra_args: Optional[list[str]] = None,
humanize: Union[bool, float] = True,
locale: str = "en-US",
locale: str = "auto",
timezone: str = "",
extra_prefs: Optional[Dict[str, Any]] = None,
binary_path: Optional[str] = None,
@ -90,6 +90,14 @@ class InvisiblePlaywright:
)
self._timezone = _geo.timezone
self._webrtc_egress_ip = _geo.egress_ip
# Geo-aware locale: "auto" derives the language from the egress country (reusing
# the egress IP just discovered), like timezone="auto". Keeps the browser language
# consistent with the proxy's country instead of a fixed en-US.
if (self._locale or "").strip().lower() == "auto":
from ._geo import resolve_session_locale
self._locale = await asyncio.to_thread(
resolve_session_locale, _geo.egress_ip, self._proxy
)
executable = self._binary_path or ensure_binary()
prefs = translate_profile_to_prefs(
self._profile,
@ -113,7 +121,7 @@ class InvisiblePlaywright:
prefs["stealthfox.humanize.maxTime"] = str(cap)
playwright_proxy = _configure_proxy_shared(self._proxy, prefs)
pw_headless = self._resolve_headless()
env = self._build_env()
env = self._build_env(prefs)
try:
self._pw = await async_playwright().start()
if self._profile_dir is not None:
@ -214,11 +222,13 @@ class InvisiblePlaywright:
pass
self._virtual_display = None
def _build_env(self) -> Dict[str, str]:
def _build_env(self, prefs: Dict[str, Any]) -> Dict[str, str]:
import os as _os
env = _os.environ.copy()
if self._timezone:
env["TZ"] = _tz_env(self._timezone)
# Fonts need NO env: the patched binary is self-contained (always
# bundle-only). No external font list / allow-list / system-ui.
# WebRTC srflx override: feed nICEr's nr_stealth_bridge the proxy egress
# IP (caller's explicit env var wins, else the IP auto-discovered in
# __aenter__) and drop IPv6 from gathering behind a proxy.

View file

@ -1,112 +1,4 @@
"""Public helpers for building Firefox launch config without using ``InvisiblePlaywright``.
Use these when you need to call ``playwright.firefox.launch()`` (or
``firefox.launch_persistent_context()``) directly with our patched binary
and stealth prefs, instead of using the ``InvisiblePlaywright`` context
manager.
Typical caller is an external integration that owns its own browser
lifecycle (a Crawlee/Skyvern/changedetection-style fetcher, a Playwright
Server wrapper, a multi-language harness) and just wants the building
blocks::
from playwright.async_api import async_playwright
from invisible_playwright import ensure_binary, get_default_stealth_prefs
async with async_playwright() as p:
browser = await p.firefox.launch(
executable_path=str(ensure_binary()),
firefox_user_prefs=get_default_stealth_prefs(seed=42),
)
For everyday Python usage the ``InvisiblePlaywright`` context manager is
still the recommended entry point; these helpers expose the same internals
without the lifecycle ownership.
.. note::
When calling ``firefox.launch()`` yourself, pass ``headless=False`` and
manage the display hiding (Xvfb on Linux, hidden desktop on Windows)
externally. Passing ``headless=True`` directly to Playwright puts
Firefox in true headless mode, which skips the real rendering pipeline
and breaks canvas / audio / WebGL fingerprint coherence. The
``InvisiblePlaywright`` context manager does this translation
automatically; the public helpers leave it to the caller.
"""
from __future__ import annotations
import secrets
from typing import Any, Dict, List, Optional, Union
from ._fpforge import generate_profile
from ._webgl_personas import forced_gpu_class
from .prefs import translate_profile_to_prefs
def get_default_stealth_prefs(
seed: Optional[int] = None,
*,
pin: Optional[Dict[str, Any]] = None,
locale: str = "en-US",
timezone: str = "",
extra_prefs: Optional[Dict[str, Any]] = None,
humanize: Union[bool, float] = True,
virtual_display: bool = False,
) -> Dict[str, Any]:
"""Build a complete ``firefox_user_prefs`` dict for ``firefox.launch()``.
Same prefs that ``InvisiblePlaywright(seed=..., locale=..., timezone=...,
extra_prefs=..., humanize=...)`` would inject. Use this when you need to
drive ``playwright.firefox.launch()`` yourself.
Args:
seed: Integer seed for the Bayesian fingerprint sampler. Same seed
produces the same fingerprint. ``None`` generates a fresh
random int31 (matches ``InvisiblePlaywright`` default).
pin: Optional dict forcing specific fingerprint fields while the
rest stays seed-derived. See ``docs/pinning.md``.
locale: BCP-47 tag (e.g. ``"en-US"``). Drives ``Accept-Language``
and ``navigator.language``.
timezone: IANA timezone (e.g. ``"America/New_York"``). Empty means
use the host TZ. This pure pref builder does NOT resolve
``"auto"`` (that needs the proxy + a network lookup at launch
time) pass a concrete zone here, or use ``InvisiblePlaywright``
/ ``resolve_session_timezone(timezone, proxy)`` for ``"auto"``.
extra_prefs: Optional dict overlaid LAST onto the generated prefs.
humanize: When True (default), every mouse move is expanded into
a Bezier trajectory by the patched Juggler. A float caps the
motion in seconds. False disables the behavior.
virtual_display: When True on Windows, apply GPU-disabling prefs
to prevent GPU process crashes on virtual desktops without
D3D11 backend.
Returns:
Dict ready to pass as ``firefox_user_prefs=`` to
``playwright.firefox.launch()`` or ``launch_persistent_context()``.
"""
resolved_seed = int(seed) if seed is not None else secrets.randbits(31)
profile = generate_profile(resolved_seed, pin=pin, fixed_gpu_class=forced_gpu_class(resolved_seed))
prefs = translate_profile_to_prefs(
profile,
locale=locale,
timezone=timezone,
extra_prefs=extra_prefs,
virtual_display=virtual_display,
)
# stealthfox.* is the namespace the binary's Juggler reads (see launcher.py note).
prefs["stealthfox.humanize"] = bool(humanize)
if humanize:
max_seconds = float(humanize) if not isinstance(humanize, bool) else 1.5
prefs["stealthfox.humanize.maxTime"] = str(max_seconds)
return prefs
def get_default_args() -> List[str]:
"""Return the default Firefox CLI args to pass via ``args=``.
Currently empty list, since all our stealth configuration is delivered
via ``firefox_user_prefs`` rather than CLI flags. Exposed for parity
with the ``cloakbrowser.config.get_default_stealth_args`` pattern and
to future-proof integrations that already wire ``args=[*existing,
*get_default_args()]``.
"""
return []
"""Backward-compat shim — spostato in invisible_core.config (alias completo)."""
import sys as _sys
from invisible_core import config as _mod
_sys.modules[__name__] = _mod

View file

@ -1,80 +1,4 @@
"""Compile-time constants that pin the wrapper to a specific Firefox build.
BINARY_VERSION is bumped every time new Firefox patches are released. It is
deliberately decoupled from the Python package version so that pure-Python
bugfixes don't force a multi-hour Firefox rebuild.
"""
from __future__ import annotations
# Bump this when a new patched Firefox build is released on GitHub.
BINARY_VERSION: str = "firefox-11"
# Releases known to be broken — ensure_binary() refuses them with a clear error
# instead of handing the user an unusable binary. firefox-8 was packaged without
# the juggler automation layer, so Playwright cannot drive it (TargetClosedError);
# fixed in firefox-9 (package-manifest.in now ships chrome/juggler). A cached
# firefox-8 from before the bump would otherwise keep being used silently.
BROKEN_VERSIONS: frozenset[str] = frozenset({"firefox-8"})
# Underlying Firefox version (for display only; does not drive downloads).
FIREFOX_UPSTREAM_VERSION: str = "150.0.1"
# The base filename prefix used inside archives.
BINARY_BASENAME: str = f"firefox-{FIREFOX_UPSTREAM_VERSION}-stealth"
def ARCHIVE_NAME(platform_key: str, machine: str) -> str:
"""Return the platform-specific archive filename.
platform_key: sys.platform ("win32", "linux", "darwin")
machine: platform.machine() ("AMD64", "x86_64", "arm64", "aarch64", ...)
"""
pk = platform_key.lower()
m = machine.lower()
if m in {"amd64", "x86_64"}:
arch = "x86_64"
elif m in {"arm64", "aarch64"}:
arch = "arm64"
else:
raise NotImplementedError(f"unsupported arch: {machine}")
if pk == "win32":
return f"{BINARY_BASENAME}-win-{arch}.zip"
if pk == "linux":
return f"{BINARY_BASENAME}-linux-{arch}.tar.gz"
if pk == "darwin":
return f"{BINARY_BASENAME}-macos-{arch}.tar.gz"
raise NotImplementedError(f"unsupported platform: {platform_key}")
# Binary entry point relative path inside the extracted archive root.
# macOS ships the .app bundle (renamed to a stable "Firefox.app" by release.yml);
# the wrapper execs the inner binary directly, which sidesteps Gatekeeper.
BINARY_ENTRY_REL = {
"win32": "firefox.exe",
"linux": "firefox",
"darwin": "Firefox.app/Contents/MacOS/firefox",
}
# GitHub release URL template. The "TODO" owner is resolved at publication time.
RELEASE_URL_TEMPLATE = (
"https://github.com/feder-cr/invisible_playwright/releases/download/{tag}/{asset}"
)
# ─────────────────────────────────────────────────────────────────────────
# GeoIP database (timezone="auto" → resolve IANA zone from proxy egress IP)
# ─────────────────────────────────────────────────────────────────────────
# daijro/geoip-all-in-one merges IP2Location LITE + GeoLite2 + DB-IP into a
# single mmdb (country ISO + coordinates + IANA timezone via tzfpy), rebuilt
# weekly. GPL-3.0, so we DOWNLOAD it at runtime into the user cache (like the
# Firefox binary) rather than bundling it into this MIT package. The `-all`
# variant covers IPv4+IPv6. download.py tracks the LATEST release and refreshes
# weekly; GEOIP_MMDB_VERSION is only the cold-cache fallback when the GitHub
# API is unreachable on a machine that has never downloaded the DB.
GEOIP_REPO: str = "daijro/geoip-all-in-one"
GEOIP_MMDB_VERSION: str = "2026.06.03"
GEOIP_ASSET: str = "geoip-aio-all.mmdb.zip"
GEOIP_MMDB_NAME: str = "geoip-aio-all.mmdb"
GEOIP_RELEASE_URL_TEMPLATE: str = (
"https://github.com/daijro/geoip-all-in-one/releases/download/{tag}/{asset}"
)
"""Backward-compat shim — spostato in invisible_core.constants (alias completo)."""
import sys as _sys
from invisible_core import constants as _mod
_sys.modules[__name__] = _mod

File diff suppressed because it is too large Load diff

View file

@ -1,328 +1,4 @@
"""Download and cache the patched Firefox binary from GitHub Releases."""
from __future__ import annotations
import hashlib
import os
import platform
import re
import shutil
import subprocess
import sys
import tarfile
import tempfile
import time
import zipfile
from pathlib import Path
import platformdirs
import requests
from .constants import (
ARCHIVE_NAME,
BINARY_ENTRY_REL,
BINARY_VERSION,
BROKEN_VERSIONS,
GEOIP_ASSET,
GEOIP_MMDB_NAME,
GEOIP_MMDB_VERSION,
GEOIP_RELEASE_URL_TEMPLATE,
RELEASE_URL_TEMPLATE,
)
def _github_token() -> str | None:
return os.environ.get("STEALTHFOX_GITHUB_TOKEN") or os.environ.get("GITHUB_TOKEN")
def _parse_owner_repo(template: str) -> tuple[str, str]:
"""Extract (owner, repo) from RELEASE_URL_TEMPLATE."""
m = re.match(r"https://github\.com/([^/]+)/([^/]+)/releases/", template)
if not m:
raise RuntimeError(f"cannot parse owner/repo from {template!r}")
return m.group(1), m.group(2)
def cache_root() -> Path:
"""Directory where all cached binaries live."""
return Path(platformdirs.user_cache_dir("invisible-playwright"))
def cache_dir_for_version(version: str = BINARY_VERSION) -> Path:
return cache_root() / version
def _resolve_asset_url(tag: str, asset_name: str) -> str:
"""Return a downloadable URL for the asset.
For private repos the direct `releases/download/<tag>/<asset>` URL returns
404 even with a token, so we resolve via the API: list assets for the
release tag, find the one matching `asset_name`, and use its API URL with
`Accept: application/octet-stream` (which 302-redirects to a signed URL).
For public repos the direct URL still works without a token.
"""
token = _github_token()
if not token:
return RELEASE_URL_TEMPLATE.format(tag=tag, asset=asset_name)
owner, repo = _parse_owner_repo(RELEASE_URL_TEMPLATE)
api = f"https://api.github.com/repos/{owner}/{repo}/releases/tags/{tag}"
r = requests.get(api, headers={"Authorization": f"token {token}"}, timeout=30)
r.raise_for_status()
for a in r.json().get("assets", []):
if a.get("name") == asset_name:
return a["url"]
raise RuntimeError(f"asset {asset_name!r} not found in release {tag!r}")
def _download_file(url: str, dst: Path, chunk_size: int = 1 << 16) -> None:
dst.parent.mkdir(parents=True, exist_ok=True)
headers: dict[str, str] = {}
token = _github_token()
if token and url.startswith("https://api.github.com/"):
headers["Authorization"] = f"token {token}"
headers["Accept"] = "application/octet-stream"
with requests.get(url, stream=True, timeout=60, headers=headers) as r:
r.raise_for_status()
with open(dst, "wb") as f:
for chunk in r.iter_content(chunk_size):
if chunk:
f.write(chunk)
def _sha256_file(path: Path) -> str:
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1 << 16), b""):
h.update(chunk)
return h.hexdigest()
def _parse_checksums(text: str) -> dict[str, str]:
out: dict[str, str] = {}
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
parts = line.split()
if len(parts) >= 2:
# sha256sum uses ' *' or ' ' prefix for binary vs text mode
key = parts[-1].lstrip("*")
out[key] = parts[0]
return out
def _extract(archive: Path, dst: Path) -> None:
dst.mkdir(parents=True, exist_ok=True)
if archive.suffix == ".zip":
with zipfile.ZipFile(archive) as zf:
zf.extractall(dst)
elif archive.name.endswith(".tar.gz") or archive.suffix in {".tgz", ".gz"}:
with tarfile.open(archive, "r:gz") as tf:
tf.extractall(dst)
else:
raise RuntimeError(f"unknown archive format: {archive}")
def _post_extract_darwin(app_root: Path, entry: Path) -> None:
"""Make an ad-hoc-signed .app launchable on macOS.
The .app is downloaded via requests (no Finder quarantine attached), but we
strip com.apple.quarantine defensively and ensure the inner binary is
executable. We exec the inner binary directly (not via LaunchServices), so
Gatekeeper's first-launch prompt does not apply; the ad-hoc signature
(applied in release.yml) is what lets the arm64 Mach-O run at all.
"""
app = app_root
# walk up to the .app bundle dir if entry points inside it
for parent in entry.parents:
if parent.name.endswith(".app"):
app = parent
break
try:
subprocess.run(["xattr", "-dr", "com.apple.quarantine", str(app)], check=False)
except FileNotFoundError:
pass
try:
entry.chmod(0o755)
except OSError:
pass
def ensure_binary(version: str = BINARY_VERSION) -> Path:
"""Return a path to a runnable Firefox executable. Download if needed."""
if version in BROKEN_VERSIONS:
raise RuntimeError(
f"{version} is a known-broken release (the juggler automation layer is "
f"missing, so Playwright cannot drive it). Upgrade invisible_playwright "
f"(current BINARY_VERSION={BINARY_VERSION}) or pass a newer version."
)
plat = sys.platform
mach = platform.machine()
asset = ARCHIVE_NAME(plat, mach)
entry_rel = BINARY_ENTRY_REL.get(plat)
if entry_rel is None:
raise NotImplementedError(f"no binary entry for platform {plat}")
version_dir = cache_dir_for_version(version)
entry = version_dir / entry_rel
if entry.exists():
return entry
url_archive = _resolve_asset_url(version, asset)
url_sums = _resolve_asset_url(version, "checksums.txt")
with tempfile.TemporaryDirectory() as td:
tmp = Path(td)
archive_path = tmp / asset
_download_file(url_archive, archive_path)
sums_path = tmp / "checksums.txt"
_download_file(url_sums, sums_path)
sums = _parse_checksums(sums_path.read_text())
expected = sums.get(asset)
if expected is None:
raise RuntimeError(f"no SHA256 for {asset} in checksums.txt")
actual = _sha256_file(archive_path)
if actual.lower() != expected.lower():
raise RuntimeError(
f"SHA256 mismatch for {asset}: got {actual}, expected {expected}"
)
_extract(archive_path, version_dir)
if plat == "darwin":
_post_extract_darwin(version_dir, entry)
if not entry.exists():
raise RuntimeError(f"binary not found after extraction: {entry}")
return entry
# ─────────────────────────────────────────────────────────────────────────
# GeoIP mmdb (timezone="auto" → map egress IP → IANA zone)
#
# daijro/geoip-all-in-one is rebuilt WEEKLY, so we don't pin a tag. We cache
# the latest mmdb and, once it's older than GEOIP_REFRESH_DAYS, re-check the
# latest release and pull a newer build if one exists. Net effect: no download
# (not even an API call) on a launch within the window; auto-refresh after it;
# a stale cache is reused when offline rather than breaking the launch.
# ─────────────────────────────────────────────────────────────────────────
GEOIP_REFRESH_DAYS = 7 # matches daijro's weekly rebuild cadence
def _geoip_root() -> Path:
return cache_root() / "geoip"
def _geoip_check_marker() -> Path:
return _geoip_root() / ".last_check"
def _cached_geoip_mmdb() -> Path | None:
"""Newest cached mmdb across tag dirs, or None. Tag dirs are date strings
(e.g. ``2026.06.03``) so a lexical sort is chronological."""
root = _geoip_root()
if not root.exists():
return None
cands = sorted(root.glob("*/*.mmdb"))
return cands[-1] if cands else None
def _geoip_cache_fresh(max_age_days: int) -> bool:
marker = _geoip_check_marker()
if not marker.exists():
return False
return (time.time() - marker.stat().st_mtime) < max_age_days * 86400
def _touch_geoip_marker() -> None:
m = _geoip_check_marker()
m.parent.mkdir(parents=True, exist_ok=True)
m.touch()
def _latest_geoip_tag() -> str:
"""Latest ``daijro/geoip-all-in-one`` release tag via the GitHub API."""
headers = {"Accept": "application/vnd.github+json"}
token = _github_token()
if token:
headers["Authorization"] = f"token {token}"
r = requests.get(
f"https://api.github.com/repos/{GEOIP_REPO}/releases/latest",
headers=headers, timeout=15,
)
r.raise_for_status()
tag = r.json().get("tag_name")
if not tag:
raise RuntimeError("no tag_name in geoip-all-in-one latest release")
return tag
def _download_geoip_tag(tag: str) -> Path:
"""Download + extract a specific tag's mmdb if not already cached."""
dst_dir = _geoip_root() / tag
target = dst_dir / GEOIP_MMDB_NAME
if not target.exists():
url = GEOIP_RELEASE_URL_TEMPLATE.format(tag=tag, asset=GEOIP_ASSET)
dst_dir.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory() as td:
archive = Path(td) / GEOIP_ASSET
_download_file(url, archive)
_extract(archive, dst_dir)
if target.exists():
return target
# asset name inside the zip may differ from GEOIP_MMDB_NAME
found = sorted(dst_dir.glob("*.mmdb"))
if found:
return found[0]
raise RuntimeError(f"geoip mmdb not found after extraction in {dst_dir}")
def _prune_old_geoip_tags(keep: str) -> None:
"""Drop every cached tag dir except ``keep`` to bound disk usage."""
root = _geoip_root()
if not root.exists():
return
for d in root.iterdir():
if d.is_dir() and d.name != keep:
shutil.rmtree(d, ignore_errors=True)
def geoip_mmdb_path() -> Path | None:
"""Path to the currently-cached mmdb (newest tag), or None if none cached."""
return _cached_geoip_mmdb()
def ensure_geoip_mmdb(max_age_days: int = GEOIP_REFRESH_DAYS) -> Path:
"""Return a geoip mmdb, kept fresh against daijro's weekly rebuild.
Resolution order:
1. ``STEALTHFOX_GEOIP_MMDB`` env use that file (user-supplied / test).
2. A cached mmdb younger than ``max_age_days`` use it (no network).
3. Else ask GitHub for the latest tag, download it if not already cached,
prune older tags, and reset the freshness timer.
4. If the API/download is unreachable but a cached mmdb exists use it
(and reset the timer so we don't hammer the API while offline).
5. Cold cache + no network fall back to the pinned ``GEOIP_MMDB_VERSION``;
if that download also fails, raise.
"""
override = os.environ.get("STEALTHFOX_GEOIP_MMDB")
if override:
p = Path(override)
if not p.exists():
raise RuntimeError(f"STEALTHFOX_GEOIP_MMDB points to a missing file: {p}")
return p
cached = _cached_geoip_mmdb()
if cached and _geoip_cache_fresh(max_age_days):
return cached
try:
tag = _latest_geoip_tag()
except Exception:
if cached:
_touch_geoip_marker() # recheck after the window; don't hammer
return cached
tag = GEOIP_MMDB_VERSION # cold cache + API down → pinned fallback
mmdb = _download_geoip_tag(tag)
_prune_old_geoip_tags(mmdb.parent.name)
_touch_geoip_marker()
return mmdb
"""Backward-compat shim — spostato in invisible_core.download (alias completo)."""
import sys as _sys
from invisible_core import download as _mod
_sys.modules[__name__] = _mod

View file

@ -110,7 +110,7 @@ class InvisiblePlaywright:
proxy: Optional[Dict[str, str]] = None,
extra_args: Optional[list[str]] = None,
humanize: Union[bool, float] = True,
locale: str = "en-US",
locale: str = "auto",
timezone: str = "",
extra_prefs: Optional[Dict[str, Any]] = None,
binary_path: Optional[str] = None,
@ -135,8 +135,15 @@ class InvisiblePlaywright:
into a Bezier trajectory with ~10 ms between waypoints.
Default ``True`` (~1.5 s max motion). ``False`` disables;
a float caps the motion in seconds.
locale: BCP-47 tag (e.g. ``"en-US"``). Drives the
``Accept-Language`` header and ``navigator.language``.
locale: BCP-47 tag (e.g. ``"en-US"``) or ``"auto"`` (default).
``"auto"`` derives the locale from the egress country the proxy
egress IP, or the host's public IP without a proxy — exactly like
``timezone="auto"``, keeping the browser language consistent with the
exit country (a French proxy ``fr-FR``). Drives
``intl.accept_languages`` both ``navigator.language``/``languages``
AND the q-valued ``Accept-Language`` header (the patched binary builds
the header from the pref, never from the raw Playwright locale override,
so the two never diverge see nsHttpHandler STEALTHFOX note).
timezone: IANA zone (e.g. ``"America/New_York"``) used as-is
when set, the only way to force a specific zone. ``""``
(default) or ``"auto"`` ALWAYS resolves from the egress IP:
@ -199,11 +206,17 @@ class InvisiblePlaywright:
_geo = prepare_session_geo(self._timezone, self._proxy)
self._timezone = _geo.timezone
self._webrtc_egress_ip = _geo.egress_ip
# Geo-aware locale: "auto" derives the language from the egress country (reusing
# the egress IP already discovered above), like timezone="auto". Keeps the browser
# language consistent with the proxy's country instead of a fixed en-US.
if (self._locale or "").strip().lower() == "auto":
from ._geo import resolve_session_locale
self._locale = resolve_session_locale(_geo.egress_ip, self._proxy)
executable = self._binary_path or ensure_binary()
prefs = self._build_prefs()
playwright_proxy = _configure_proxy_shared(self._proxy, prefs)
pw_headless = self._resolve_headless()
env = self._build_env()
env = self._build_env(prefs)
try:
self._pw = sync_playwright().start()
@ -358,7 +371,7 @@ class InvisiblePlaywright:
prefs["stealthfox.humanize.maxTime"] = str(self._humanize_max_seconds())
return prefs
def _build_env(self) -> Dict[str, str]:
def _build_env(self, prefs: Dict[str, Any]) -> Dict[str, str]:
"""Env vars passed to the Firefox subprocess.
``TZ`` tunes the libc clock the content process reads for
@ -369,6 +382,9 @@ class InvisiblePlaywright:
a synthetic srflx candidate matching the proxy egress IP, avoiding
the StaticPref IPC propagation timing issue between parent and
socket processes.
Fonts need NO env: the patched binary is self-contained (always
bundle-only, exposing exactly the bundled standard-Windows families;
system-ui + generics baked in C++). No external font list / allow-list.
"""
import os as _os
env = _os.environ.copy()

View file

@ -1,666 +1,4 @@
"""Translate an internal Profile into the Firefox prefs dict that the
patched Firefox binary expects.
The output dict keys map 1:1 to ``user.js`` preferences. Playwright passes
them via ``firefox_user_prefs=``. The patched binary propagates them to all
content processes over IPC; C++ patches read the ``zoom.stealth.*``
namespace.
The translation is split into:
* ``_BASELINE`` global stealth policy (RFP off, WebRTC leaks blocked,
safebrowsing disabled, debugger detach, ) plus Windows-canonical
constants that don't depend on the Profile (system colors palette,
WebGL extensions whitelist, speech voices, navigator identity).
* ``translate_profile_to_prefs`` overlays the Profile fields plus the
user-supplied ``locale`` and ``timezone``.
"""
from __future__ import annotations
import sys
from typing import Any, Dict, Optional
from ._fpforge import Profile
from ._webgl_personas import render_noise_seed, select_persona
# ──────────────────────────────────────────────────────────────────────
# Navigator identity — locked to Firefox 150 Windows so the binary
# reports the same UA / platform / oscpu regardless of the host OS.
# ──────────────────────────────────────────────────────────────────────
_NAVIGATOR_OVERRIDES: Dict[str, str] = {
"general.useragent.override":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:150.0) "
"Gecko/20100101 Firefox/150.0.1",
"general.platform.override": "Win32",
"general.oscpu.override": "Windows NT 10.0; Win64; x64",
# general.buildID.override removed 2026-04-28: the previous value
# "20181001000000" was a 2018 buildID stuck on a 2026-built Firefox 150
# binary (real BuildID=20260426192818 from application.ini). The 7.5-yr
# discrepancy is the kind of internal-consistency check Google reCAPTCHA
# can use to flag bot/spoofed browsers. Deleting the override lets
# Firefox emit its compiled-in buildID, which auto-tracks the binary.
# A/B knockout 2026-04-28 (n=30): F2 delete +0.083 RC vs BASE; n=100
# confirm: +0.021; overnight isolated: +0.155 single-variant. Variable
# signal, but the underlying data error is unambiguous.
"general.appversion.override": "5.0 (Windows)",
}
# ──────────────────────────────────────────────────────────────────────
# System colors — FP Pro probes getComputedStyle(div) with CSS system
# keywords (ButtonFace, Menu, Highlight, …) and hashes the result into
# signal s142. On Linux, Firefox resolves these via GTK theme → GTK
# RGB values diverge from Windows Win32 palette → server-side anomaly
# even with Windows UA. Pinning the palette to Win10 default closes
# the gap (see project_css_system_colors.md memory).
# ──────────────────────────────────────────────────────────────────────
_WIN_LIGHT_COLORS: Dict[str, str] = {
"ui.activeborder": "#B4B4B4",
"ui.activecaption": "#99B4D1",
"ui.appworkspace": "#ABABAB",
"ui.background": "#000000",
"ui.buttonface": "#F0F0F0",
"ui.buttonhighlight": "#FFFFFF",
"ui.buttonshadow": "#A0A0A0",
"ui.buttontext": "#000000",
"ui.buttonborder": "#000000",
"ui.captiontext": "#000000",
"ui.graytext": "#6D6D6D",
"ui.highlight": "#0078D7",
"ui.highlighttext": "#FFFFFF",
"ui.inactiveborder": "#F4F7FC",
"ui.inactivecaption": "#BFCDDB",
"ui.inactivecaptiontext": "#434E54",
"ui.infobackground": "#FFFFE1",
"ui.infotext": "#000000",
"ui.menu": "#F9F9FB",
"ui.menutext": "#000000",
"ui.scrollbar": "#C8C8C8",
"ui.threeddarkshadow": "#696969",
"ui.threedface": "#F0F0F0",
"ui.threedhighlight": "#FFFFFF",
"ui.threedlightshadow": "#E3E3E3",
"ui.threedshadow": "#A0A0A0",
"ui.window": "#FFFFFF",
"ui.windowframe": "#646464",
"ui.windowtext": "#000000",
"ui.mark": "#FFFF00",
"ui.marktext": "#000000",
"ui.accentcolor": "#0078D4",
"ui.accentcolortext": "#FFFFFF",
"ui.selecteditem": "#0078D7",
"ui.selecteditemtext": "#FFFFFF",
"ui.-moz-hyperlinktext": "#0066CC",
"ui.-moz-activehyperlinktext": "#EE0000",
"ui.-moz-visitedhyperlinktext": "#551A8B",
}
# ──────────────────────────────────────────────────────────────────────
# WebGL extensions — Windows ANGLE canonical lists. Empty string =
# fall back to native Mesa/ANGLE; non-empty = `getSupportedExtensions`
# returns this list verbatim and `IsSupported()` rejects anything else.
# ──────────────────────────────────────────────────────────────────────
_WEBGL1_EXTENSIONS = ",".join([
"ANGLE_instanced_arrays",
"EXT_blend_minmax",
"EXT_color_buffer_half_float",
"EXT_float_blend",
"EXT_frag_depth",
"EXT_sRGB",
"EXT_shader_texture_lod",
"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_s3tc",
"WEBGL_compressed_texture_s3tc_srgb",
"WEBGL_debug_renderer_info",
"WEBGL_debug_shaders",
"WEBGL_depth_texture",
"WEBGL_draw_buffers",
"WEBGL_lose_context",
"WEBGL_provoking_vertex",
])
_WEBGL2_EXTENSIONS = ",".join([
"EXT_color_buffer_float",
"EXT_color_buffer_half_float",
"EXT_float_blend",
"EXT_texture_compression_bptc",
"EXT_texture_compression_rgtc",
"EXT_texture_filter_anisotropic",
"OES_draw_buffers_indexed",
"OES_texture_float_linear",
"OES_texture_half_float_linear",
"OVR_multiview2",
"WEBGL_compressed_texture_s3tc",
"WEBGL_compressed_texture_s3tc_srgb",
"WEBGL_debug_renderer_info",
"WEBGL_debug_shaders",
"WEBGL_lose_context",
"WEBGL_provoking_vertex",
])
# ──────────────────────────────────────────────────────────────────────
# Speech voices — Windows canonical "Microsoft *" set. Format:
# "NAME|LANG|DEFAULT|LOCAL,...". Non-empty value drives the
# speechSynthesis.getVoices() patch; empty disables it.
# ──────────────────────────────────────────────────────────────────────
_WIN_VOICES = ",".join([
"Microsoft David - English (United States)|en-US|1|1",
"Microsoft Zira - English (United States)|en-US|0|1",
"Microsoft Mark - English (United States)|en-US|0|1",
"Microsoft David Desktop - English (United States)|en-US|0|1",
"Microsoft Zira Desktop - English (United States)|en-US|0|1",
])
# ──────────────────────────────────────────────────────────────────────
# Linux font compensation — Linux Firefox uses DejaVu / Liberation
# fonts which have wider/narrower glyphs than Windows Arial / Segoe.
# These per-generic factors are prepended to ``zoom.stealth.font.metrics``
# on Linux only; Windows-native rendering already matches the canonical
# widths so we pass an empty string (any factor !=1 would distort real
# metrics).
# ──────────────────────────────────────────────────────────────────────
_LINUX_GENERIC_FONT_FACTORS = (
# Calibrated to bring DejaVu/Liberation widths in line with what Windows
# FP Pro probes report for native Segoe/Times. Linux base measurements
# (font_preferences) and Windows targets:
# serif: 162 → 149 factor 0.920
# sans: 162 → 144 factor 0.889
# monospace:121 → 121 factor 1.000
# system: 162 → 147 factor 0.910
"serif|0.920,sans-serif|0.889,monospace|1.000,"
"system-ui|0.910,cursive|0.932,fantasy|0.812,"
)
# ──────────────────────────────────────────────────────────────────────
# Baseline — applied to every session regardless of Profile.
# ──────────────────────────────────────────────────────────────────────
_BASELINE: Dict[str, Any] = {
# Turn off Firefox's own resistFingerprinting; we do our own via patches.
"privacy.resistFingerprinting": False,
"privacy.resistFingerprinting.letterboxing": False,
# FF150 fingerprintingProtection — enabled by default (or remotely via
# Mozilla webcompat overrides). FP Pro detects the side-effects and
# flips `privacy_settings: true`. On FF146 these were all off → False.
# Force off so FP Pro reports privacy_settings:false (matches FF146).
"privacy.fingerprintingProtection": False,
"privacy.fingerprintingProtection.pbmode": False,
"privacy.fingerprintingProtection.remoteOverrides.enabled": False,
# WebRTC: enabled, looks like a real Firefox behind NAT, no real-IP leak.
# obfuscate_host_addresses=true → host candidate is `<uuid>.local` mDNS,
# exactly like vanilla Firefox (BrowserLeaks "No Leak", Local IP "-").
# The mDNS-IPC hang feared on older builds does NOT reproduce on FF150.
# The proxy-egress srflx is injected by our C++ (srflx swap §17 + fallback
# §17.B), fed the egress IP via STEALTHFOX_WEBRTC_PUBLIC_IP from
# launcher._build_env (auto-discovered from the proxy).
# IPv6: media.peerconnection.ice.disableIPv6 is DEAD on FF150 (read by no
# ICE-gathering code). The real switch is our zoom.stealth.webrtc.disable_ipv6
# (nICEr addrs.cpp filter) + the STEALTHFOX_WEBRTC_DISABLE_IPV6 env.
"media.peerconnection.enabled": True,
"media.peerconnection.ice.no_host": False,
"media.peerconnection.ice.default_address_only": False,
"media.peerconnection.ice.obfuscate_host_addresses": True,
"zoom.stealth.webrtc.disable_ipv6": True,
"media.peerconnection.ice.proxy_only": False,
"media.peerconnection.ice.relay_only": False,
"media.peerconnection.use_document_iceservers": True,
# Proxy — route DNS through SOCKS proxies to avoid local DNS leaks.
"network.proxy.socks_remote_dns": True,
"network.proxy.failover_direct": False,
# TLS ClientHello fingerprint — match stock Firefox byte-for-byte.
# The Playwright/Juggler Firefox build this binary derives from re-enables
# cipher 0xC009 (TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA), which retail Firefox
# 150 does NOT offer. That extra (17th) cipher shifts our JA3/JA4 away from
# any real Firefox (ja4 t13d1717h2 vs stock t13d1617h2). A ClientHello that
# matches no real browser is itself a consistency tell. Disabling it makes
# JA3/JA4/peetprint byte-identical to retail FF150 (verified on tls.peet.ws).
# Stock Firefox ships without 0xC009 and works on the whole web, so this only
# improves fingerprint consistency — it cannot break connectivity.
"security.ssl3.ecdhe_ecdsa_aes_128_sha": False,
# Safebrowsing — chatty and fingerprintable.
"browser.safebrowsing.malware.enabled": False,
"browser.safebrowsing.phishing.enabled": False,
"browser.safebrowsing.downloads.enabled": False,
"browser.safebrowsing.downloads.remote.enabled": False,
# First-run / welcome UI noise.
"browser.startup.page": 0,
"browser.shell.checkDefaultBrowser": False,
"browser.aboutwelcome.enabled": False,
"browser.startup.upgradeDialog.enabled": False,
"termsofuse.acceptedVersion": 999,
# Disable about:newtab auto-load — TopSitesFeed.sys.mjs auto-fetches when
# a tab opens, triggering a cross-process BC swap that hijacks the first
# page.goto() (NS_BINDING_ABORTED on creepjs/peet/sannysoft/fppro).
"browser.newtabpage.enabled": False,
"browser.newtab.preload": False,
"browser.newtabpage.activity-stream.feeds.topsites": False,
"browser.newtabpage.activity-stream.feeds.section.topstories": False,
"browser.newtabpage.activity-stream.enabled": False,
# Disable Firefox internal services that hit the network on startup.
# Through a residential SOCKS5 proxy these compete with the test
# navigation and trigger NS_BINDING_FAILED (server-side rate-limit /
# connection drops). Domains observed in MOZ_LOG: push.services,
# firefox.settings.services, detectportal, ohttp-gateway, location.
"browser.aboutConfig.showWarning": False,
"network.captive-portal-service.enabled": False,
"network.connectivity-service.enabled": False,
"dom.push.enabled": False,
"dom.push.connection.enabled": False,
"geo.enabled": False,
"geo.provider.network.url": "",
"browser.region.network.url": "",
"browser.region.update.enabled": False,
"services.settings.server": "",
"browser.search.geoSpecificDefaults": False,
"browser.contentblocking.report.lockwise.enabled": False,
"browser.contentblocking.report.monitor.enabled": False,
"extensions.systemAddon.update.enabled": False,
"extensions.update.enabled": False,
"extensions.getAddons.cache.enabled": False,
"browser.discovery.enabled": False,
"browser.ping-centre.telemetry": False,
"app.normandy.enabled": False,
"dom.private-attribution.submission.enabled": False,
"browser.translations.enable": False,
"browser.search.update": False,
# HTTP/3 + speculative + Alt-Svc disabled. SOCKS5 proxy doesn't
# support UDP ASSOCIATE so HTTP/3 fails. Speculative connections
# under load cause early channel cancel (NS_BINDING_FAILED).
"network.http.http3.enable": False,
"network.http.http3.enabled": False,
"network.http.altsvc.enabled": False,
"network.http.altsvc.oe": False,
"network.http.speculative-parallel-limit": 0,
"network.predictor.enabled": False,
"network.dns.disablePrefetch": True,
"network.dns.disablePrefetchFromHTTPS": True,
"network.dns.echconfig.enabled": False,
"network.dns.use_https_rr_as_altsvc": False,
# === Fission / site-isolation disabled (FF146 Playwright parity) ===
# Force a single content-process model. Three knobs are required in FF150:
# upstream Playwright Firefox (FF146-based) only needed fission.autostart=False
# because FF146's default isolation strategy was looser. FF150 ships with
# fission.webContentIsolationStrategy=1 (IsolateEverything) which still
# site-isolates cross-origin iframes into separate `webIsolated` content
# processes EVEN WHEN fission.autostart is False. From the parent process's
# point of view, those iframes get a Juggler Frame placeholder with no
# docShell, no URL, and an execution context that wraps the wrong global,
# so frame.evaluate() fails with cross-origin SOP errors and
# element_handle.content_frame() returns None.
#
# Pinning the strategy to 0 keeps every cross-origin web iframe in the
# parent's content process, where the Juggler code paths from the FF146
# era expect them. processCount.webIsolated=1 is kept as belt-and-suspenders
# in case some path still classifies an origin as webIsolated despite the
# strategy change. It costs nothing to leave.
#
# See issue #20 + tests/test_cross_origin_iframe.py for the regression
# sentinel that catches a future A/B flipping these back.
"fission.autostart": False,
"fission.autostart.session": False,
"fission.webContentIsolationStrategy": 0, # IsolateNothing
"dom.ipc.processCount.webIsolated": 1,
# Telemetry & data reporting.
"datareporting.healthreport.uploadEnabled": False,
"datareporting.policy.dataSubmissionEnabled": False,
"toolkit.telemetry.enabled": False,
"toolkit.telemetry.unified": False,
"app.shield.optoutstudies.enabled": False,
# Update channels.
"app.update.enabled": False,
"app.update.auto": False,
# Speech synth: enabled (the C++ patch fabricates voices from the
# comma list above) regardless of the host OS.
"media.webspeech.synth.enabled": True,
"zoom.stealth.voices.list": _WIN_VOICES,
# WebGL extensions whitelist — non-empty pre-empts native enumeration.
"zoom.stealth.webgl.extensions": _WEBGL1_EXTENSIONS,
"zoom.stealth.webgl2.extensions": _WEBGL2_EXTENSIONS,
# WebGL numeric param overrides — kept empty (A/B test 2026-04-22 showed
# mismatches between the values we shipped and ANGLE's real envelope
# raised FP Pro's ML tampering score). Slot kept for future experiments.
"zoom.stealth.webgl.int_params": "",
"zoom.stealth.webgl.int2_params": "",
"zoom.stealth.webgl.shader_precisions": "",
"zoom.stealth.webgl.float_params": "",
# DevTools anti-detection.
"zoom.stealth.debugger.force_detach": True,
# Canvas substitution — additive ±1 noise over the OS base pattern;
# set to True to replace pixels with hash(seed, idx) instead.
"zoom.stealth.canvas.substitute_pixels": False,
# Navigator identity (locked to Windows Firefox 150).
**_NAVIGATOR_OVERRIDES,
}
# ──────────────────────────────────────────────────────────────────────
# Linux-only Xvfb workarounds — the Linux Firefox build under Xvfb
# cannot run WebRender (`ConnectToCompositor` retries forever). We
# disable WebRender + force WebGL through the GL software path so
# webgl_basics / webgl_extensions still report.
# ──────────────────────────────────────────────────────────────────────
_LINUX_XVFB_WORKAROUNDS: Dict[str, Any] = {
"gfx.webrender.all": False,
"gfx.webrender.force-disabled": True,
"webgl.force-enabled": True,
# webgl.software-rendering-enabled / webgl.force-layers-readback removed in FF150.
}
# ──────────────────────────────────────────────────────────────────────
# Windows virtual-desktop workarounds — when headless=True on Windows,
# Firefox runs on a CreateDesktop virtual desktop. The hardware GPU is
# inaccessible from the virtual desktop, so the GPU process crashes when
# it tries to initialize the D3D11 compositor with hardware acceleration.
#
# Approach: force D3D11 WARP (CPU software renderer) for the GPU process.
# layers.d3d11.force-warp=True → compositor uses WARP → GPU process stable.
# webgl.angle.force-warp=True → ANGLE uses WARP → WebGL context creates.
#
# CRITICAL: do NOT set webgl.out-of-process=False. That moves WebGL from the
# GPU process to the sandboxed content process. The content process sandbox
# blocks D3D11 access entirely → ANGLE crashes the content process →
# canvas.getContext('webgl') throws instead of returning null.
#
# gfx.canvas.accelerated=False: default is true, disabling avoids any
# hardware GPU dependency for 2D canvas in the content process.
# ──────────────────────────────────────────────────────────────────────
_WIN_VIRT_DESKTOP_WORKAROUNDS: Dict[str, Any] = {
# FF150 regression vs FF146 on CreateDesktop alt-desktop:
# The GPU process sandbox (level=1, default since FF110) tries to parent
# its compositor window to the parent process's window. Our worker spawns
# Firefox on a CreateDesktop-created alt desktop — parent and GPU process
# do not share the same desktop/HWND namespace, so window parenting fails
# silently. WebRender falls back to "Software D3D11" and OOP-WebGL never
# publishes a hardware ANGLE renderer → getContext('webgl') returns a
# context but extensions/parameters/$hash all come back null/empty (FF146
# had a more permissive sandbox, so the same setup worked there).
# Bugzilla refs: 1798091, 1524591, 1229829. Lowering the GPU sandbox to 0
# restores hardware compositor + functional WebGL on alt desktops.
"security.sandbox.gpu.level": 0,
# Same root cause as above, content process side. Wrapper repo issue #18
# (tab crash on cross-process navigation under headless=True). Sandbox
# content level > 4 puts content processes on the sandbox's own
# kAlternateWinstation (see security/sandbox/win/src/sandboxbroker/
# sandboxBroker.cpp line 1113-1114:
# `if (aSandboxLevel > 4) config->SetDesktop(kAlternateWinstation)`).
# Combined with our CreateDesktop alt-desktop, that puts browser process
# and content processes on DIFFERENT desktops. Cross-process navigation
# then fails window parenting between parent and child, the content
# process exits cleanly (exitCode=0, signal=null) and Playwright fires
# page.on('crash') ~10s after page load. Lowering content sandbox to 4
# keeps content processes on the same desktop as the browser process,
# which is what we want here (still tight enough — level 4 blocks
# file/registry write, network calls, hardware access).
"security.sandbox.content.level": 4,
}
# ──────────────────────────────────────────────────────────────────────
# Public helpers
# ──────────────────────────────────────────────────────────────────────
def _accept_language(locale: str) -> str:
lang = locale.replace("_", "-")
base = lang.split("-")[0]
return f"{lang}, {base}" if base != lang else lang
def _font_metrics_for_platform(profile_metrics: str) -> str:
"""Return ``zoom.stealth.font.metrics`` value.
The C++ whitelist hook (``gfxPlatformFontList::FindAndAddFamiliesLocked``)
backs EVERY whitelisted *named* family with the list-head family on every
platform. Without per-font width factors, that means each named font
(Arial, Times New Roman, Courier New, ) renders with identical glyphs and
collapses to a SINGLE canvas ``measureText`` width a non-physical
1-distinct-width result that strict JS-sensor anti-bots flag via their
font probe. The per-font factors in ``profile_metrics``
(``arial|0.978,arial black|1.168,``) spread the fabricated families back
to distinct, realistic, deterministic-per-seed widths, so we apply them on
EVERY platform (previously suppressed on Windows/mac, which left the
collapse in place only the CSS-generic vector, which FP Pro probes, was
ever correct there).
These factors only key *named* families. CSS generics
(serif/sans-serif/monospace/system-ui) bypass the whitelist entirely and
render at the host's native widths, so they are never present in
``profile_metrics`` and stay unfactored FP Pro's ``font_preferences``
probe (which measures the generics) is unaffected. That is also why
applying named-font factors here does NOT distort the canonical generic
widths.
Linux ADDITIONALLY needs generic-family compensation
(``_LINUX_GENERIC_FONT_FACTORS``) because DejaVu/Liberation generics render
wider/narrower than the Windows widths the spoofed profile claims; on
Windows/mac the generics already render native, so no generic compensation
is applied only the named-font factors.
"""
if not profile_metrics:
return ""
if sys.platform.startswith("linux"):
return _LINUX_GENERIC_FONT_FACTORS + profile_metrics
# Windows / macOS: named-font factors only (the generics render native and
# bypass the whitelist, so no generic compensation — but the named families
# MUST be factored or they all collapse to the list-head width).
return profile_metrics
def translate_profile_to_prefs(
profile: Profile,
*,
locale: str = "en-US",
timezone: str = "",
extra_prefs: Optional[Dict[str, Any]] = None,
virtual_display: bool = False,
) -> Dict[str, Any]:
"""Return a complete prefs dict ready for Playwright's firefox_user_prefs=.
Args:
profile: Bayesian-sampled fingerprint (from ``generate_profile``).
locale: BCP-47 tag, e.g. ``"en-US"``.
timezone: IANA timezone name, e.g. ``"America/New_York"``.
extra_prefs: Optional overlay applied LAST.
virtual_display: When True on Windows, apply GPU-disabling workarounds
to prevent the GPU process from crashing on virtual
desktops that have no D3D11 backend.
"""
prefs: Dict[str, Any] = dict(_BASELINE)
# GPU / WebGL renderer/vendor.
# On Linux we spoof to a Windows ANGLE renderer string (profile.gpu.renderer)
# so cross-platform sessions report a consistent Windows GPU identity.
# On Windows/mac, spoofing a renderer string ALONE is unsafe — the ~81
# getParameter values stay real, so a name↔params hash mismatch FP Pro flags
# (setting GTX 980 over real Arc A750 params scored ~0.70). Instead we apply a
# VALIDATED PERSONA (see _webgl_personas): a {renderer, vendor} whose params are
# the shared ANGLE D3D11 caps (vendor-independent — identical on any host, per the
# ANGLE source) and whose extension list is FORCED below. That is a coherent fake
# GPU that passes FP Pro host-independently (the host's real GPU never leaks). If no
# validated persona exists for the sampled gpu_class yet, fall back to the host-real
# renderer (empty → native ANGLE; SanitizeRenderer at ClientWebGLContext.cpp:2592).
_persona = None
if sys.platform.startswith("linux"):
prefs["zoom.stealth.webgl.renderer"] = profile.gpu.renderer
prefs["zoom.stealth.webgl.vendor"] = profile.gpu.vendor
_renderer_lo = (profile.gpu.renderer or "").lower()
else:
_persona = select_persona(profile.seed)
if _persona:
prefs["zoom.stealth.webgl.renderer"] = _persona["renderer"]
prefs["zoom.stealth.webgl.vendor"] = _persona["vendor"]
else:
prefs["zoom.stealth.webgl.renderer"] = ""
prefs["zoom.stealth.webgl.vendor"] = ""
# Canvas-noise mask is calibrated to the REAL host GPU's rendering variance — the canvas is
# drawn by real hardware, NOT the persona's claimed GPU, so it must NOT follow the persona
# (a non-Intel persona on an Intel host would over-noise). Deployment host is Intel.
_renderer_lo = "intel"
# MSAA: on Windows, pin to 4 (Firefox default for ANGLE) so gl.SAMPLES is
# constant across all sessions. Different MSAA values cause different CN-set
# parameters hashes even with the same renderer → detectable variation.
# Vanilla Intel Arc A750 parameters hash (66544db8) verified at msaa=4.
_msaa = profile.webgl.msaa_samples if sys.platform.startswith("linux") else 4
prefs["zoom.stealth.webgl.msaa"] = _msaa
prefs["webgl.msaa-samples"] = _msaa
prefs["webgl.msaa-force"] = _msaa > 0
# Canvas pixel-noise density per vendor. Intel has lower natural
# rendering variance than NVIDIA/AMD, so the default 1/8 noise rate
# over-amplifies the FP Pro tampering ML signal. Drop to 1/16 for Intel
# to keep tampering_ml below the detection threshold while still
# breaking the canvas geometry hash.
if "intel" in _renderer_lo:
prefs["zoom.stealth.canvas.noise_skip_mask"] = 15 # 1/16, ~6.25%
else:
prefs["zoom.stealth.canvas.noise_skip_mask"] = 7 # 1/8, ~12.5%
# Screen
prefs["zoom.stealth.screen.width"] = profile.screen.width
prefs["zoom.stealth.screen.height"] = profile.screen.height
prefs["zoom.stealth.screen.avail_width"] = profile.screen.avail_width
prefs["zoom.stealth.screen.avail_height"] = profile.screen.avail_height
prefs["zoom.stealth.screen.dpr"] = profile.screen.dpr
prefs["layout.css.devPixelsPerPx"] = str(profile.screen.dpr)
# Hardware — coherent with the sampled gpu_class by construction (the forge
# draws hw_concurrency conditioned on the GPU class).
prefs["zoom.stealth.hw_concurrency"] = profile.hardware.concurrency
prefs["zoom.stealth.storage.quota_mb"] = profile.hardware.storage_quota_mb
# Audio
prefs["zoom.stealth.audio.sample_rate"] = profile.audio.sample_rate
prefs["zoom.stealth.audio.output_latency_ms"] = profile.audio.output_latency_ms
prefs["zoom.stealth.audio.max_channel_count"] = profile.audio.max_channel_count
# Codec
prefs["media.av1.enabled"] = profile.codec.av1_enabled
prefs["media.encoder.webm.enabled"] = profile.codec.webm_encoder_enabled
prefs["media.mediasource.webm.enabled"] = profile.codec.mediasource_webm
prefs["media.mediasource.mp4.enabled"] = profile.codec.mediasource_mp4
# Fonts
prefs["zoom.stealth.font.whitelist"] = ",".join(profile.fonts)
prefs["zoom.stealth.font.metrics"] = _font_metrics_for_platform(
profile._raw.get("font_metrics", "") or ""
)
# UI / dark mode + Windows colors palette (only when light theme).
prefs["ui.systemUsesDarkTheme"] = int(profile.dark_theme)
if not profile.dark_theme:
prefs.update(_WIN_LIGHT_COLORS)
# Locale prefs.
locale = locale or "en-US"
lang = locale.replace("_", "-")
prefs["intl.accept_languages"] = _accept_language(locale)
prefs["general.useragent.locale"] = lang
prefs["intl.locale.requested"] = lang
prefs["privacy.spoof_english"] = 0
if timezone:
# juggler.timezone.override is the SOLE source of truth read by the C++
# timezone chain (BrowsingContext::Attach/DidSet, ContentChild). The old
# zoom.stealth.timezone pref was declared in the yaml but read by NO
# code — dropped here on 2026-06-10 (see 20-our-patches.md §8).
prefs["juggler.timezone.override"] = timezone
# Cross-process seed (canvas noise + DWrite gamma share this). Only
# zoom.stealth.fpp.hw_seed is read by the C++; the old zoom.stealth.seed
# alias was never declared in the yaml and read by nothing — dropped
# 2026-06-10. The render-noise seed is DECOUPLED from the identity seed and
# drawn from a calibrated CLEAN pool: the canvas/WebGL render HASH it drives
# is the dominant FP Pro tampering_ml signal, and some hw_seeds yield a
# "suspicious" render hash. render_noise_seed() maps to the clean pool while
# keeping per-seed determinism + diversity. See _webgl_personas.
prefs["zoom.stealth.fpp.hw_seed"] = render_noise_seed(profile.seed)
# Synthetic host ICE candidate — injected by C++ when addr_ct==0 (SOCKS5
# proxy suppresses all local addresses so Firefox can't gather host cands).
# LAN IP is seed-derived so it's consistent per session and looks like a
# real home router assignment (192.168.x.x range).
_s = profile.seed
_lan_ip = f"192.168.{(_s >> 8) % 254 + 1}.{_s % 254 + 1}"
prefs["zoom.stealth.webrtc.host_ip"] = _lan_ip
# Windows/mac extension list:
# - persona active → FORCE the validated extension list. A non-Intel host's native
# extensions would mismatch the persona's renderer (renderer says AMD/Intel-Arc but
# extensions are the host's), so the persona must carry its own list to stay
# host-independent.
# - no persona → clear so the host-real renderer reports its native extension set
# (matches real vanilla captures for that host's GPU).
if not sys.platform.startswith("linux"):
if _persona:
# The persona carries its OWN extension lists in EXACT NATIVE ORDER — a
# reordered/foreign list is flagged by FP Pro (verified 2026-06-13).
prefs["zoom.stealth.webgl.extensions"] = _persona["ext1"]
prefs["zoom.stealth.webgl2.extensions"] = _persona["ext2"]
else:
prefs["zoom.stealth.webgl.extensions"] = ""
prefs["zoom.stealth.webgl2.extensions"] = ""
# Linux Xvfb workarounds (no-op on Windows).
if sys.platform.startswith("linux"):
for k, v in _LINUX_XVFB_WORKAROUNDS.items():
prefs.setdefault(k, v)
# Windows virtual-desktop workarounds (headless=True on Windows).
if virtual_display and sys.platform == "win32":
for k, v in _WIN_VIRT_DESKTOP_WORKAROUNDS.items():
prefs.setdefault(k, v)
# Caller overlay LAST so users can override anything we set. A value of
# None is treated as a sentinel meaning "delete this pref entirely from
# the final dict" — useful for A/B harnesses that need to test what
# happens when an override is unset (vs set to empty string, which for
# some prefs like general.useragent.override means literally empty UA).
if extra_prefs:
for k, v in extra_prefs.items():
if v is None:
prefs.pop(k, None)
else:
prefs[k] = v
return prefs
"""Backward-compat shim — spostato in invisible_core.prefs (alias completo)."""
import sys as _sys
from invisible_core import prefs as _mod
_sys.modules[__name__] = _mod

84
tests/test_backcompat.py Normal file
View file

@ -0,0 +1,84 @@
"""Backward-compatibility guard for the invisible_core / invisible_playwright split.
After extracting the pure config into ``invisible_core`` and turning the moved
modules into aliasing shims, EVERY import path that external users (and our own
outreach PRs) rely on must keep working same objects, same behavior. This test
is the contract: if it breaks, we broke someone's install.
"""
import importlib
import pytest
@pytest.mark.unit
def test_top_level_public_api_imports():
import invisible_playwright as ip
for name in [
"InvisiblePlaywright", "ensure_binary", "ensure_geoip_mmdb",
"get_default_stealth_prefs", "get_default_args",
"resolve_session_timezone", "GeoTimezoneError",
"BINARY_VERSION", "FIREFOX_UPSTREAM_VERSION", "__version__",
]:
assert hasattr(ip, name), f"invisible_playwright.{name} missing"
@pytest.mark.unit
def test_submodule_imports_external_users_rely_on():
# These are the exact import shapes used in the wild + in our Skyvern/onyx/etc PRs.
from invisible_playwright._fpforge import generate_profile, Profile # noqa: F401
from invisible_playwright._webgl_personas import forced_gpu_class # noqa: F401
from invisible_playwright.prefs import translate_profile_to_prefs # noqa: F401
from invisible_playwright.download import ensure_binary # noqa: F401
from invisible_playwright.constants import BINARY_VERSION # noqa: F401
from invisible_playwright.config import get_default_stealth_prefs # noqa: F401
@pytest.mark.unit
def test_deep_submodule_and_private_names_still_import():
from invisible_playwright._fpforge.profile import Profile # noqa: F401
from invisible_playwright._fpforge import _sampler # noqa: F401
from invisible_playwright.prefs import _BASELINE # noqa: F401
assert isinstance(_BASELINE, dict)
@pytest.mark.unit
def test_class_identity_is_shared_with_core():
# isinstance must hold: the object returned by generate_profile must be an
# instance of BOTH the invisible_playwright-path Profile and the core Profile
# (they must be the SAME class object, not duplicates).
from invisible_playwright._fpforge import generate_profile
from invisible_playwright._fpforge import Profile as PW_Profile
from invisible_playwright._fpforge.profile import Profile as PW_Profile2
import invisible_core
from invisible_core import Profile as Core_Profile
p = generate_profile(seed=42)
assert isinstance(p, PW_Profile)
assert isinstance(p, PW_Profile2)
assert isinstance(p, Core_Profile)
assert PW_Profile is Core_Profile
assert PW_Profile2 is Core_Profile
@pytest.mark.unit
def test_shim_output_matches_core_output():
from invisible_playwright._fpforge import generate_profile as gp_pw
from invisible_playwright._webgl_personas import forced_gpu_class as fgc_pw
from invisible_playwright.prefs import translate_profile_to_prefs as tp_pw
import invisible_core as ic
a = tp_pw(gp_pw(42, fixed_gpu_class=fgc_pw(42)))
b = ic.translate_profile_to_prefs(ic.generate_profile(42, fixed_gpu_class=ic.forced_gpu_class(42)))
assert a == b
@pytest.mark.unit
def test_core_imports_without_playwright(monkeypatch):
# invisible_core must be importable even if playwright is absent. We simulate
# by asserting the core modules never pulled playwright into sys.modules as a
# side effect of their own import (the wrapper may have, but core must not need it).
import sys
import invisible_core # noqa: F401
# invisible_core itself must not hard-depend on playwright at import time.
core_file = importlib.import_module("invisible_core").__file__
assert "invisible_core" in core_file

View file

@ -0,0 +1,67 @@
"""Canvas / WebGL render-stealth regression test (binary-level, 2026-06-18).
Guards a patched-binary behaviour that must never regress, needed for the
fingerprint to look like a real Windows browser to FOSS detectors (CreepJS,
FingerprintJS, BrowserLeaks) and fixed-hash reference checks:
Solid WebGL readback purity under render-noise a fixed solid-colour WebGL
readback (which reference checks hash against a universal constant) must stay
byte-exact even with per-seed render-noise enabled, while high-entropy
renders stay noised. (C++: render-noise skips near-uniform WebGL readbacks.)
(Per-font canvas distinctness is no longer guarded here: the font-collapse +
per-font draw offset were removed on 2026-06-20 in favour of real bundled
Windows fonts, which rasterise to distinct images by nature.)
Runs against about:blank, no network/proxy. Part of the e2e release gate.
Run: pytest tests/test_canvas_render_stealth.py -m e2e -v
"""
from __future__ import annotations
import pytest
from invisible_playwright import InvisiblePlaywright
@pytest.fixture(scope="module")
def noised_page(firefox_binary):
"""Headless session with render-noise explicitly ON (positive hw_seed) so the
purity / distinctness guards actually exercise the noise path."""
with InvisiblePlaywright(
seed=42,
binary_path=firefox_binary,
headless=True,
extra_prefs={"zoom.stealth.fpp.hw_seed": 24680},
) as browser:
p = browser.new_context().new_page()
p.goto("about:blank", timeout=30_000)
yield p
@pytest.mark.e2e
def test_solid_webgl_readback_stays_pure_under_noise(noised_page):
"""A solid-colour WebGL readback must remain byte-exact (only {0,255}) with
render-noise on. Regression: the noise drifted edge pixels 255->254 on some GL
backends (Linux ANGLE-over-GL), breaking fixed-hash reference checks ('oe')."""
res = noised_page.evaluate(
"""() => {
const c = document.createElement('canvas'); c.width = 256; c.height = 24;
const gl = c.getContext('webgl', {preserveDrawingBuffer: true});
if (!gl) return {ok: false, reason: 'no-webgl'};
gl.clearColor(1, 0, 0, 1); gl.clear(gl.COLOR_BUFFER_BIT);
const buf = new Uint8Array(256 * 24 * 4);
gl.finish(); gl.readPixels(0, 0, 256, 24, gl.RGBA, gl.UNSIGNED_BYTE, buf);
const vals = new Set();
for (let i = 0; i < buf.length; i++) vals.add(buf[i]);
return {ok: true, vals: Array.from(vals).sort((a, b) => a - b)};
}"""
)
if not res["ok"]:
pytest.skip(res.get("reason", "webgl unavailable"))
assert res["vals"] == [0, 255], \
f"solid WebGL readback not pure under noise: values {res['vals']} (uniform-skip regressed?)"
# NOTE: "high-entropy WebGL still noised" is covered by test_webgl_noise_active.py
# (kept separate: it launches its own browsers, so it must not run while this
# module's shared `noised_page` browser is open — the sync API cannot nest).

View file

@ -171,7 +171,7 @@ def test_release_url_points_at_owned_repo():
"""The template MUST point at an owner/repo the maintainer actually
controls. A typo here would direct everyone's downloads at a stranger's
GitHub account silent supply-chain risk."""
assert "/feder-cr/invisible_playwright/" in RELEASE_URL_TEMPLATE, (
assert "/feder-cr/firefox_antidetect_patch/" in RELEASE_URL_TEMPLATE, (
f"RELEASE_URL_TEMPLATE was changed to point elsewhere: "
f"{RELEASE_URL_TEMPLATE!r}. Update this test only if the move is intentional."
)

View file

@ -56,8 +56,8 @@ def test_ensure_binary_downloads_and_verifies(tmp_path, monkeypatch):
from invisible_playwright.constants import ARCHIVE_NAME
asset = ARCHIVE_NAME("win32", "AMD64")
url_archive = f"https://github.com/feder-cr/invisible_playwright/releases/download/{BINARY_VERSION}/{asset}"
url_sums = f"https://github.com/feder-cr/invisible_playwright/releases/download/{BINARY_VERSION}/checksums.txt"
url_archive = RELEASE_URL_TEMPLATE.format(tag=BINARY_VERSION, asset=asset)
url_sums = RELEASE_URL_TEMPLATE.format(tag=BINARY_VERSION, asset="checksums.txt")
responses.add(responses.GET, url_archive, body=archive_bytes, status=200,
content_type="application/zip")
@ -84,8 +84,8 @@ def test_ensure_binary_rejects_sha_mismatch(tmp_path, monkeypatch):
from invisible_playwright.constants import ARCHIVE_NAME
asset = ARCHIVE_NAME("win32", "AMD64")
url_archive = f"https://github.com/feder-cr/invisible_playwright/releases/download/{BINARY_VERSION}/{asset}"
url_sums = f"https://github.com/feder-cr/invisible_playwright/releases/download/{BINARY_VERSION}/checksums.txt"
url_archive = RELEASE_URL_TEMPLATE.format(tag=BINARY_VERSION, asset=asset)
url_sums = RELEASE_URL_TEMPLATE.format(tag=BINARY_VERSION, asset="checksums.txt")
responses.add(responses.GET, url_archive, body=archive_bytes, status=200)
responses.add(responses.GET, url_sums, body=f"{wrong_sha} {asset}\n", status=200)
@ -301,14 +301,8 @@ def test_ensure_binary_accepts_binary_mode_checksums(tmp_path, monkeypatch):
from invisible_playwright.constants import ARCHIVE_NAME
asset = ARCHIVE_NAME("win32", "AMD64")
url_archive = (
f"https://github.com/feder-cr/invisible_playwright/releases/download/"
f"{BINARY_VERSION}/{asset}"
)
url_sums = (
f"https://github.com/feder-cr/invisible_playwright/releases/download/"
f"{BINARY_VERSION}/checksums.txt"
)
url_archive = RELEASE_URL_TEMPLATE.format(tag=BINARY_VERSION, asset=asset)
url_sums = RELEASE_URL_TEMPLATE.format(tag=BINARY_VERSION, asset="checksums.txt")
responses.add(responses.GET, url_archive, body=archive_bytes, status=200,
content_type="application/zip")
@ -353,8 +347,8 @@ def test_ensure_binary_missing_entry_after_extract_raises(tmp_path, monkeypatch)
from invisible_playwright.constants import ARCHIVE_NAME
asset = ARCHIVE_NAME("win32", "AMD64")
url_archive = f"https://github.com/feder-cr/invisible_playwright/releases/download/{BINARY_VERSION}/{asset}"
url_sums = f"https://github.com/feder-cr/invisible_playwright/releases/download/{BINARY_VERSION}/checksums.txt"
url_archive = RELEASE_URL_TEMPLATE.format(tag=BINARY_VERSION, asset=asset)
url_sums = RELEASE_URL_TEMPLATE.format(tag=BINARY_VERSION, asset="checksums.txt")
responses.add(responses.GET, url_archive, body=archive_bytes, status=200)
responses.add(responses.GET, url_sums, body=f"{archive_sha} {asset}\n", status=200)
@ -444,8 +438,8 @@ def test_ensure_binary_downloads_and_verifies_linux(tmp_path, monkeypatch):
from invisible_playwright.constants import ARCHIVE_NAME
asset = ARCHIVE_NAME("linux", "x86_64")
url_archive = f"https://github.com/feder-cr/invisible_playwright/releases/download/{BINARY_VERSION}/{asset}"
url_sums = f"https://github.com/feder-cr/invisible_playwright/releases/download/{BINARY_VERSION}/checksums.txt"
url_archive = RELEASE_URL_TEMPLATE.format(tag=BINARY_VERSION, asset=asset)
url_sums = RELEASE_URL_TEMPLATE.format(tag=BINARY_VERSION, asset="checksums.txt")
responses.add(responses.GET, url_archive, body=archive_bytes, status=200,
content_type="application/gzip")
@ -475,8 +469,8 @@ def test_ensure_binary_rejects_sha_mismatch_linux(tmp_path, monkeypatch):
from invisible_playwright.constants import ARCHIVE_NAME
asset = ARCHIVE_NAME("linux", "x86_64")
url_archive = f"https://github.com/feder-cr/invisible_playwright/releases/download/{BINARY_VERSION}/{asset}"
url_sums = f"https://github.com/feder-cr/invisible_playwright/releases/download/{BINARY_VERSION}/checksums.txt"
url_archive = RELEASE_URL_TEMPLATE.format(tag=BINARY_VERSION, asset=asset)
url_sums = RELEASE_URL_TEMPLATE.format(tag=BINARY_VERSION, asset="checksums.txt")
responses.add(responses.GET, url_archive, body=archive_bytes, status=200)
responses.add(responses.GET, url_sums, body=f"{wrong_sha} {asset}\n", status=200)
@ -528,8 +522,8 @@ def test_ensure_binary_missing_entry_after_extract_raises_linux(tmp_path, monkey
from invisible_playwright.constants import ARCHIVE_NAME
asset = ARCHIVE_NAME("linux", "x86_64")
url_archive = f"https://github.com/feder-cr/invisible_playwright/releases/download/{BINARY_VERSION}/{asset}"
url_sums = f"https://github.com/feder-cr/invisible_playwright/releases/download/{BINARY_VERSION}/checksums.txt"
url_archive = RELEASE_URL_TEMPLATE.format(tag=BINARY_VERSION, asset=asset)
url_sums = RELEASE_URL_TEMPLATE.format(tag=BINARY_VERSION, asset="checksums.txt")
responses.add(responses.GET, url_archive, body=archive_bytes, status=200)
responses.add(responses.GET, url_sums, body=f"{archive_sha} {asset}\n", status=200)
@ -569,7 +563,7 @@ def test_resolve_asset_url_public_url_format_is_stable(monkeypatch):
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
url = _resolve_asset_url("firefox-4", "abc.tar.gz")
assert url == (
"https://github.com/feder-cr/invisible_playwright/releases/"
"https://github.com/feder-cr/firefox_antidetect_patch/releases/"
"download/firefox-4/abc.tar.gz"
)
@ -584,7 +578,7 @@ def test_resolve_asset_url_private_uses_api_with_token(monkeypatch):
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
api_url = (
"https://api.github.com/repos/feder-cr/invisible_playwright"
"https://api.github.com/repos/feder-cr/firefox_antidetect_patch"
"/releases/tags/firefox-4"
)
responses.add(
@ -606,7 +600,7 @@ def test_resolve_asset_url_private_raises_when_asset_missing(monkeypatch):
with the asset name in the message than to download something else."""
monkeypatch.setenv("STEALTHFOX_GITHUB_TOKEN", "ghp_fake")
api_url = (
"https://api.github.com/repos/feder-cr/invisible_playwright"
"https://api.github.com/repos/feder-cr/firefox_antidetect_patch"
"/releases/tags/firefox-4"
)
responses.add(
@ -625,7 +619,7 @@ def test_resolve_asset_url_private_propagates_api_4xx(monkeypatch):
don't swallow it silently — raise so the user sees the real reason."""
monkeypatch.setenv("STEALTHFOX_GITHUB_TOKEN", "ghp_fake")
api_url = (
"https://api.github.com/repos/feder-cr/invisible_playwright"
"https://api.github.com/repos/feder-cr/firefox_antidetect_patch"
"/releases/tags/firefox-99"
)
responses.add(responses.GET, api_url, status=404)
@ -640,7 +634,7 @@ def test_resolve_asset_url_private_sends_auth_header(monkeypatch):
a private repo returns 404 and the user thinks the release is missing."""
monkeypatch.setenv("STEALTHFOX_GITHUB_TOKEN", "ghp_secret")
api_url = (
"https://api.github.com/repos/feder-cr/invisible_playwright"
"https://api.github.com/repos/feder-cr/firefox_antidetect_patch"
"/releases/tags/firefox-4"
)

View file

@ -1,13 +1,11 @@
"""Unit tests for the intelligent geoip mmdb auto-update in `download.py`.
"""Unit tests for the geoip mmdb auto-update in `download.py`.
daijro/geoip-all-in-one rebuilds weekly; `ensure_geoip_mmdb` keeps the cache
fresh without a download (or API call) on every launch. These tests mock the
cache root, the latest-tag API, and the per-tag download so nothing touches the
network.
daijro/geoip-all-in-one rebuilds weekly and keeps only the latest ~2 releases,
so `ensure_geoip_mmdb` never pins a tag: on every call it resolves the CURRENT
latest tag (from the `releases/latest/download` permalink, no GitHub API) and
downloads it only when it differs from the cache. These tests mock the cache
root, the tag resolver, and the per-tag download so nothing touches the network.
"""
import os
import time
import pytest
import invisible_playwright.download as dl
@ -29,14 +27,6 @@ def _make_cached(root, tag, name=dl.GEOIP_MMDB_NAME):
return f
def _set_marker_age(root, days):
m = root / "geoip" / ".last_check"
m.parent.mkdir(parents=True, exist_ok=True)
m.touch()
old = time.time() - days * 86400
os.utime(m, (old, old))
# ──────────────────────────────────────────────────────────────────────
# env override
# ──────────────────────────────────────────────────────────────────────
@ -56,76 +46,97 @@ def test_env_override_missing_raises(tmp_path, monkeypatch):
# ──────────────────────────────────────────────────────────────────────
# freshness window
# every-launch latest check
# ──────────────────────────────────────────────────────────────────────
@pytest.mark.unit
def test_fresh_cache_no_network(cache, monkeypatch):
f = _make_cached(cache, "2026.06.03")
_set_marker_age(cache, 0) # just checked
def boom():
raise AssertionError("latest-tag API must NOT be called within the window")
monkeypatch.setattr(dl, "_latest_geoip_tag", boom)
assert dl.ensure_geoip_mmdb(max_age_days=7) == f
@pytest.mark.unit
def test_stale_same_tag_no_download(cache, monkeypatch):
f = _make_cached(cache, "2026.06.03")
_set_marker_age(cache, 30) # stale → will re-check
monkeypatch.setattr(dl, "_latest_geoip_tag", lambda: "2026.06.03")
# real _download_geoip_tag runs but target exists, so no actual download:
def test_cache_is_latest_no_download(cache, monkeypatch):
f = _make_cached(cache, "2026.06.17")
monkeypatch.setattr(dl, "_resolve_latest_geoip_tag", lambda: "2026.06.17")
monkeypatch.setattr(dl, "_download_file", lambda *a, **k: (_ for _ in ()).throw(
AssertionError("must not download when tag already cached")))
assert dl.ensure_geoip_mmdb(max_age_days=7) == f
AssertionError("must not download when cache already on the latest tag")))
assert dl.ensure_geoip_mmdb() == f
@pytest.mark.unit
def test_stale_new_tag_downloads_and_prunes(cache, monkeypatch):
old = _make_cached(cache, "2026.06.03")
_set_marker_age(cache, 30)
monkeypatch.setattr(dl, "_latest_geoip_tag", lambda: "2026.06.10")
def fake_download(tag):
return _make_cached(cache, tag) # simulate fetch+extract of the new tag
monkeypatch.setattr(dl, "_download_geoip_tag", fake_download)
got = dl.ensure_geoip_mmdb(max_age_days=7)
assert got.parent.name == "2026.06.10"
def test_new_tag_downloads_and_prunes(cache, monkeypatch):
old = _make_cached(cache, "2026.06.10")
monkeypatch.setattr(dl, "_resolve_latest_geoip_tag", lambda: "2026.06.17")
monkeypatch.setattr(dl, "_download_geoip_tag", lambda tag: _make_cached(cache, tag))
got = dl.ensure_geoip_mmdb()
assert got.parent.name == "2026.06.17"
assert not old.parent.exists() # old tag pruned
assert got.exists()
# ──────────────────────────────────────────────────────────────────────
# offline resilience
# ──────────────────────────────────────────────────────────────────────
@pytest.mark.unit
def test_api_down_with_cache_uses_cache(cache, monkeypatch):
f = _make_cached(cache, "2026.06.03")
_set_marker_age(cache, 30)
def boom():
raise OSError("offline")
monkeypatch.setattr(dl, "_latest_geoip_tag", boom)
assert dl.ensure_geoip_mmdb(max_age_days=7) == f # stale cache reused, no raise
@pytest.mark.unit
def test_cold_cache_api_down_falls_back_to_pinned(cache, monkeypatch):
# no cache at all + API unreachable → pinned GEOIP_MMDB_VERSION fallback.
def boom():
raise OSError("offline")
monkeypatch.setattr(dl, "_latest_geoip_tag", boom)
captured = {}
def fake_download(tag):
captured["tag"] = tag
return _make_cached(cache, tag)
monkeypatch.setattr(dl, "_download_geoip_tag", fake_download)
got = dl.ensure_geoip_mmdb(max_age_days=7)
assert captured["tag"] == dl.GEOIP_MMDB_VERSION
def test_cold_cache_downloads_latest(cache, monkeypatch):
monkeypatch.setattr(dl, "_resolve_latest_geoip_tag", lambda: "2026.06.17")
monkeypatch.setattr(dl, "_download_geoip_tag", lambda tag: _make_cached(cache, tag))
got = dl.ensure_geoip_mmdb()
assert got.parent.name == "2026.06.17"
assert got.exists()
# ──────────────────────────────────────────────────────────────────────
# offline resilience (no pinned-tag fallback — the pin rots and 404s)
# ──────────────────────────────────────────────────────────────────────
@pytest.mark.unit
def test_offline_with_cache_uses_cache(cache, monkeypatch):
f = _make_cached(cache, "2026.06.10")
monkeypatch.setattr(dl, "_resolve_latest_geoip_tag", lambda: None) # offline
monkeypatch.setattr(dl, "_download_file", lambda *a, **k: (_ for _ in ()).throw(
AssertionError("offline → must not attempt a download")))
assert dl.ensure_geoip_mmdb() == f # cache reused, no raise
@pytest.mark.unit
def test_cold_cache_offline_raises(cache, monkeypatch):
monkeypatch.setattr(dl, "_resolve_latest_geoip_tag", lambda: None) # offline
with pytest.raises(RuntimeError):
dl.ensure_geoip_mmdb()
@pytest.mark.unit
def test_download_failure_with_cache_falls_back(cache, monkeypatch):
f = _make_cached(cache, "2026.06.10")
monkeypatch.setattr(dl, "_resolve_latest_geoip_tag", lambda: "2026.06.17")
def boom(tag):
raise OSError("transient download failure")
monkeypatch.setattr(dl, "_download_geoip_tag", boom)
assert dl.ensure_geoip_mmdb() == f # keeps the old cache rather than failing
# ──────────────────────────────────────────────────────────────────────
# latest-tag resolution via the permalink 302 (no GitHub API)
# ──────────────────────────────────────────────────────────────────────
@pytest.mark.unit
def test_resolve_tag_from_permalink_redirect(monkeypatch):
class _Resp:
headers = {"Location":
"https://github.com/daijro/geoip-all-in-one/releases/download/"
"2026.06.17/geoip-aio-all.mmdb.zip"}
monkeypatch.setattr(dl.requests, "head", lambda *a, **k: _Resp())
assert dl._resolve_latest_geoip_tag() == "2026.06.17"
@pytest.mark.unit
def test_resolve_tag_permalink_fails_falls_back_to_api(monkeypatch):
def head_boom(*a, **k):
raise OSError("no network for HEAD")
monkeypatch.setattr(dl.requests, "head", head_boom)
monkeypatch.setattr(dl, "_latest_geoip_tag_api", lambda: "2026.06.17")
assert dl._resolve_latest_geoip_tag() == "2026.06.17"
@pytest.mark.unit
def test_resolve_tag_all_fail_returns_none(monkeypatch):
def boom(*a, **k):
raise OSError("offline")
monkeypatch.setattr(dl.requests, "head", boom)
monkeypatch.setattr(dl, "_latest_geoip_tag_api", boom)
assert dl._resolve_latest_geoip_tag() is None

View file

@ -42,8 +42,6 @@ _REQUIRED_PREFS_KEYS = (
"media.encoder.webm.enabled",
"media.mediasource.webm.enabled",
"media.mediasource.mp4.enabled",
"zoom.stealth.font.whitelist",
"zoom.stealth.font.metrics",
"ui.systemUsesDarkTheme",
"intl.accept_languages",
"general.useragent.locale",
@ -197,23 +195,24 @@ def test_http_proxy_returned_unchanged_no_socks_mutations():
# ──────────────────────────────────────────────────────────────────────
# IT7: profile.fonts reaches prefs as a comma-joined whitelist
# IT7: fonts are NOT configured via prefs (the binary is self-contained)
# ──────────────────────────────────────────────────────────────────────
@pytest.mark.integration
def test_profile_fonts_propagate_to_prefs_whitelist():
"""IT7 — every font in ``profile.fonts`` appears in the comma-joined
``zoom.stealth.font.whitelist`` pref, in order."""
def test_fonts_are_not_configured_via_prefs():
"""IT7 - the patched binary is self-contained for fonts: it is always
bundle-only (the exposed set IS the bundle), with system-ui + the CSS
generics baked in C++. There is no external font customization channel, so
the config pipeline must emit NO font prefs. Locks the 2026-07-06
self-contained refactor (fonts moved entirely into the binary)."""
profile = generate_profile(seed=42)
prefs = translate_profile_to_prefs(profile)
assert profile.fonts, "fixture seed=42 produced empty fonts list"
whitelist = prefs["zoom.stealth.font.whitelist"]
assert isinstance(whitelist, str)
assert whitelist == ",".join(profile.fonts)
for font in profile.fonts:
assert font in whitelist
assert "zoom.stealth.font.fontlist" not in prefs
assert "zoom.stealth.font.system_ui" not in prefs
assert not any(k.startswith("zoom.stealth.font.") for k in prefs)
assert not any(k.startswith("font.name-list.") for k in prefs)
# ──────────────────────────────────────────────────────────────────────
@ -306,9 +305,9 @@ def test_windows_virtual_display_with_socks_proxy(monkeypatch):
@pytest.mark.integration
def test_linux_xvfb_workarounds_with_socks_proxy(monkeypatch):
"""IT11 — Linux + SOCKS5 proxy: Xvfb workarounds applied, GPU renderer
spoofed from profile, SOCKS keys written. virtual_display is a Windows-
only concept so we omit it here; passing ``virtual_display=True`` on
Linux must NOT set ``security.sandbox.gpu.level`` (covered by VD3)."""
spoofed from the validated WebGL persona, SOCKS keys written. virtual_display
is a Windows-only concept so we omit it here; passing ``virtual_display=True``
on Linux must NOT set ``security.sandbox.gpu.level`` (covered by VD3)."""
monkeypatch.setattr(sys, "platform", "linux")
profile = generate_profile(seed=42)
prefs = translate_profile_to_prefs(profile, virtual_display=True)
@ -323,9 +322,15 @@ def test_linux_xvfb_workarounds_with_socks_proxy(monkeypatch):
assert prefs["webgl.force-enabled"] is True
# Windows-only sandbox key absent on Linux even with virtual_display=True.
assert "security.sandbox.gpu.level" not in prefs
# GPU renderer is spoofed from the profile (not cleared like on Windows).
assert prefs["zoom.stealth.webgl.renderer"] == profile.gpu.renderer
# GPU renderer is spoofed from the validated WebGL persona (a coherent Windows
# ANGLE GPU whose renderer + params cross-check), applied on every host — NOT the
# raw profile.gpu.renderer, which has no coherent param set and is never exposed.
from invisible_playwright._webgl_personas import select_persona
_persona = select_persona(profile.seed)
assert _persona, "expected a validated persona for this seed"
assert prefs["zoom.stealth.webgl.renderer"] == _persona["prefs"]["zoom.stealth.webgl.renderer"]
assert prefs["zoom.stealth.webgl.renderer"] # non-empty
assert "ANGLE" in prefs["zoom.stealth.webgl.renderer"] # Windows ANGLE form
# SOCKS branch wrote its keys without clobbering the Linux prefs above.
assert prefs["network.proxy.type"] == 1
assert prefs["network.proxy.socks"] == "127.0.0.1"
@ -351,23 +356,3 @@ def test_linux_msaa_pin_propagates_through_pipeline(monkeypatch):
assert prefs["webgl.msaa-force"] is True
# ──────────────────────────────────────────────────────────────────────
# IT13 (extra): Linux font metrics receive the GTK/DejaVu compensation
# block. End-to-end check that ``_LINUX_GENERIC_FONT_FACTORS`` is
# prepended to the per-font metrics string sampled from the profile.
# ──────────────────────────────────────────────────────────────────────
@pytest.mark.integration
def test_linux_font_metrics_include_generic_factors(monkeypatch):
"""IT13 — on Linux the font metrics pref starts with the generic
width-scale factors (GTK/DejaVu compensation) so glyph widths match
Windows. Without this, Linux sessions leak via metric drift."""
from invisible_playwright.prefs import _LINUX_GENERIC_FONT_FACTORS
monkeypatch.setattr(sys, "platform", "linux")
profile = generate_profile(seed=42)
prefs = translate_profile_to_prefs(profile)
metrics = prefs["zoom.stealth.font.metrics"]
assert metrics.startswith(_LINUX_GENERIC_FONT_FACTORS)

View file

@ -181,7 +181,7 @@ def test_default_context_omits_locale_when_empty():
def test_build_env_injects_webrtc_egress_when_discovered():
ip = InvisiblePlaywright(seed=42)
ip._webrtc_egress_ip = "203.0.113.9" # what __enter__ resolves behind a proxy
env = ip._build_env()
env = ip._build_env({})
assert env["STEALTHFOX_WEBRTC_PUBLIC_IP"] == "203.0.113.9"
assert env["STEALTHFOX_WEBRTC_DISABLE_IPV6"] == "1"
@ -191,7 +191,7 @@ def test_build_env_no_webrtc_keys_without_proxy(monkeypatch):
monkeypatch.delenv("STEALTHFOX_WEBRTC_PUBLIC_IP", raising=False)
ip = InvisiblePlaywright(seed=42)
ip._webrtc_egress_ip = None # no proxy → real STUN already truthful
env = ip._build_env()
env = ip._build_env({})
assert "STEALTHFOX_WEBRTC_PUBLIC_IP" not in env
assert "STEALTHFOX_WEBRTC_DISABLE_IPV6" not in env
@ -201,6 +201,29 @@ def test_build_env_caller_env_override_wins(monkeypatch):
monkeypatch.setenv("STEALTHFOX_WEBRTC_PUBLIC_IP", "198.51.100.5")
ip = InvisiblePlaywright(seed=42)
ip._webrtc_egress_ip = "203.0.113.9" # auto-discovered
env = ip._build_env()
env = ip._build_env({})
assert env["STEALTHFOX_WEBRTC_PUBLIC_IP"] == "198.51.100.5" # caller wins
assert env["STEALTHFOX_WEBRTC_DISABLE_IPV6"] == "1"
@pytest.mark.unit
def test_build_env_never_injects_font_env():
# The patched binary is self-contained for fonts (always bundle-only; the
# exposed set IS the bundle, system-ui + generics baked in C++). The wrapper
# must NOT inject any STEALTHFOX_FONTLIST/SYSTEMUI env - even if legacy font
# prefs are passed - so there is no external font customization channel.
ip = InvisiblePlaywright(seed=42)
env = ip._build_env({
"zoom.stealth.font.fontlist": "arial,calibri,segoe ui",
"zoom.stealth.font.system_ui": "Segoe UI",
})
assert "STEALTHFOX_FONTLIST" not in env
assert "STEALTHFOX_SYSTEMUI" not in env
@pytest.mark.unit
def test_build_env_no_font_keys_when_absent():
ip = InvisiblePlaywright(seed=42)
env = ip._build_env({})
assert "STEALTHFOX_FONTLIST" not in env
assert "STEALTHFOX_SYSTEMUI" not in env

View file

@ -51,22 +51,11 @@ def test_pin_key_without_dot_raises():
generate_profile(seed=42, pin={"madeup": 1})
def test_pin_top_level_fonts_accepted():
p = generate_profile(seed=42, pin={"fonts": ["Arial", "Verdana", "Tahoma"]})
assert "Arial" in p.fonts
assert "Verdana" in p.fonts
def test_pin_top_level_dark_theme_accepted():
p = generate_profile(seed=42, pin={"dark_theme": True})
assert p.dark_theme is True
def test_pin_fonts_wrong_type_raises():
with pytest.raises(TypeError, match="list/tuple"):
generate_profile(seed=42, pin={"fonts": "Arial,Verdana"})
def test_pin_overrides_seed_value():
"""The same seed produces different output once a pin is applied."""
natural = generate_profile(seed=42)

View file

@ -5,9 +5,7 @@ import pytest
from invisible_playwright._fpforge import generate_profile
from invisible_playwright.prefs import (
_LINUX_GENERIC_FONT_FACTORS,
_accept_language,
_font_metrics_for_platform,
_WIN_LIGHT_COLORS,
translate_profile_to_prefs,
)
@ -15,18 +13,18 @@ from invisible_playwright.prefs import (
@pytest.mark.unit
def test_translate_includes_gpu_renderer_windows(monkeypatch):
"""On Windows we falsify the GPU to one of the calibrated CLEAN buckets (FP Pro
tampering_ml<=0.5 on every seed; sweep 2026-06-14). Only Radeon R9 200 Series and
Intel Arc A750 ship every NVIDIA/iGPU/945 bucket is penalized. See _webgl_personas."""
"""On Windows we falsify the GPU to a real-Firefox GPU from the camoufox-derived pool
(prevalence-weighted; full coherent renderer+vendor+params+extensions). The chosen GPU's
renderer/vendor are applied verbatim and the renderer is in ANGLE D3D11 wire format."""
monkeypatch.setattr(sys, "platform", "win32")
_CLEAN = {
"ANGLE (AMD, AMD Radeon R9 200 Series Direct3D11 vs_5_0 ps_5_0, D3D11)",
"ANGLE (Intel, Intel(R) Arc(TM) A750 Graphics Direct3D11 vs_5_0 ps_5_0, D3D11)",
}
from invisible_playwright._webgl_personas import select_persona
p = generate_profile(seed=42)
prefs = translate_profile_to_prefs(p)
assert prefs["zoom.stealth.webgl.renderer"] in _CLEAN
assert prefs["zoom.stealth.webgl.vendor"] in {"Google Inc. (AMD)", "Google Inc. (Intel)"}
persona = select_persona(42)
assert prefs["zoom.stealth.webgl.renderer"] == persona["renderer"]
assert prefs["zoom.stealth.webgl.renderer"].endswith(", D3D11)")
assert prefs["zoom.stealth.webgl.vendor"] == persona["vendor"]
assert "Google Inc." in prefs["zoom.stealth.webgl.vendor"]
@pytest.mark.unit
@ -82,29 +80,6 @@ def test_accept_language_underscore_normalized():
assert _accept_language("pt_BR") == "pt-BR, pt"
# ──────────────────────────────────────────────────────────────────────
# _font_metrics_for_platform
# ──────────────────────────────────────────────────────────────────────
@pytest.mark.unit
def test_font_metrics_windows_applies_named_factors(monkeypatch):
# FM2: Windows/mac apply the per-NAMED-font factors (so whitelisted named
# families don't collapse to the list-head width on the canvas measureText
# path), but WITHOUT the Linux generic-family compensation (generics bypass
# the whitelist and render native there).
monkeypatch.setattr(sys, "platform", "win32")
out = _font_metrics_for_platform("Arial|1.0,Verdana|0.9,")
assert out == "Arial|1.0,Verdana|0.9,"
assert "sans-serif|" not in out # no generic compensation on Windows
@pytest.mark.unit
def test_font_metrics_empty_input_returns_empty():
# FM3: Empty input always returns "" regardless of platform.
assert _font_metrics_for_platform("") == ""
# ──────────────────────────────────────────────────────────────────────
# Platform-specific GPU / MSAA (Windows)
# ──────────────────────────────────────────────────────────────────────
@ -156,15 +131,17 @@ def test_canvas_noise_mask_windows_uses_intel_path(monkeypatch):
@pytest.mark.unit
def test_webgl_extensions_persona_on_windows(monkeypatch):
# WE2: with a persona active on Windows, extensions are FORCED to the persona's native-order
# list (host-independent), NOT cleared. Order is load-bearing (must match the persona verbatim).
# WE2: with a persona active on Windows, the webgl1/webgl2 extension lists are FORCED to
# the chosen GPU's real native-order lists (carried in the persona's coherent `prefs`),
# NOT cleared. Order is load-bearing (must match the GPU's real capture verbatim).
monkeypatch.setattr(sys, "platform", "win32")
from invisible_playwright._webgl_personas import select_persona
p = generate_profile(seed=42)
prefs = translate_profile_to_prefs(p)
persona = select_persona(42)
assert prefs["zoom.stealth.webgl.extensions"] == persona["ext1"]
assert prefs["zoom.stealth.webgl2.extensions"] == persona["ext2"]
assert prefs["zoom.stealth.webgl.extensions"] == persona["prefs"]["zoom.stealth.webgl.extensions"]
assert prefs["zoom.stealth.webgl2.extensions"] == persona["prefs"]["zoom.stealth.webgl2.extensions"]
assert prefs["zoom.stealth.webgl.extensions"] # non-empty (a real GPU's ext list)
# ──────────────────────────────────────────────────────────────────────
@ -383,44 +360,20 @@ def test_lan_ip_seed_zero_has_no_zero_octets():
# ──────────────────────────────────────────────────────────────────────
@pytest.mark.unit
def test_font_metrics_linux_prepends_generic_factors(monkeypatch):
# FM1: Linux prepends the GTK/DejaVu compensation block to the
# per-font metrics string sampled from the profile.
monkeypatch.setattr(sys, "platform", "linux")
out = _font_metrics_for_platform("Arial|1.0,Verdana|0.9,")
assert out.startswith(_LINUX_GENERIC_FONT_FACTORS)
assert out.endswith("Arial|1.0,Verdana|0.9,")
@pytest.mark.unit
def test_font_metrics_linux_empty_input_returns_empty(monkeypatch):
# FM1b: even on Linux, empty profile metrics short-circuits before
# the prepend so we never emit a metrics pref containing only the
# generic block (which would surface as a tampering signal).
monkeypatch.setattr(sys, "platform", "linux")
assert _font_metrics_for_platform("") == ""
@pytest.mark.unit
def test_font_metrics_linux2_variant_uses_linux_branch(monkeypatch):
# FM1c: ``sys.platform`` can be ``linux2`` on older Pythons / odd
# WSL builds. ``startswith("linux")`` accepts both.
monkeypatch.setattr(sys, "platform", "linux2")
out = _font_metrics_for_platform("Verdana|0.9,")
assert out.startswith(_LINUX_GENERIC_FONT_FACTORS)
@pytest.mark.unit
def test_gpu_renderer_set_from_profile_on_linux(monkeypatch):
# PG1: on Linux we spoof to the profile's Windows-ANGLE renderer
# string so cross-platform sessions present a consistent Windows GPU.
# PG1: on Linux (as on EVERY host) we apply the camoufox-derived Windows-ANGLE GPU persona,
# so the page sees a consistent Windows GPU (rule: always look Windows). The C++ WebGL
# override is platform-independent (SanitizeRenderer is pure string regex), so the same
# persona renderer/vendor is presented on Linux too — no more "Generic Renderer".
monkeypatch.setattr(sys, "platform", "linux")
from invisible_playwright._webgl_personas import select_persona
p = generate_profile(seed=42)
prefs = translate_profile_to_prefs(p)
assert prefs["zoom.stealth.webgl.renderer"] == p.gpu.renderer
assert prefs["zoom.stealth.webgl.vendor"] == p.gpu.vendor
assert prefs["zoom.stealth.webgl.renderer"] # non-empty
persona = select_persona(42)
assert prefs["zoom.stealth.webgl.renderer"] == persona["renderer"]
assert prefs["zoom.stealth.webgl.renderer"].endswith(", D3D11)")
assert prefs["zoom.stealth.webgl.vendor"] == persona["vendor"]
@pytest.mark.unit
@ -465,8 +418,10 @@ def test_canvas_noise_mask_intel_on_linux(monkeypatch):
@pytest.mark.unit
def test_canvas_noise_mask_nvidia_on_linux(monkeypatch):
# CN2: NVIDIA/AMD renderer → 1/8 noise (mask=7). The "intel" substring
# check must NOT match here.
# CN2: the canvas-noise mask follows the REAL HOST GPU (the canvas is drawn by real
# hardware, NOT the exposed persona), so it is the Intel-class 1/16 rate (mask=15) on the
# dev/test host even when an NVIDIA persona is exposed — the persona vendor does NOT drive
# the noise rate anymore (would over-noise on an Intel host).
monkeypatch.setattr(sys, "platform", "linux")
p = generate_profile(
seed=42,
@ -476,7 +431,7 @@ def test_canvas_noise_mask_nvidia_on_linux(monkeypatch):
},
)
prefs = translate_profile_to_prefs(p)
assert prefs["zoom.stealth.canvas.noise_skip_mask"] == 7
assert prefs["zoom.stealth.canvas.noise_skip_mask"] == 15
@pytest.mark.unit

View file

@ -21,12 +21,6 @@ from invisible_playwright._fpforge.profile import (
# _validate_pin_key
# ─────────────────────────────────────────────────────────────────────
@pytest.mark.unit
def test_validate_pin_key_top_level_fonts():
"""VK1 — `fonts` is a known top-level key."""
_validate_pin_key("fonts")
@pytest.mark.unit
def test_validate_pin_key_top_level_dark_theme():
"""VK2 — `dark_theme` is a known top-level key."""
@ -98,7 +92,6 @@ def _raw_baseline():
"screen_h": 1080,
"webgl_vendor": "Google Inc. (Intel)",
"webgl_renderer": "ANGLE (Intel)",
"font_whitelist": "arial,calibri",
"dark_theme": 0,
}
@ -110,34 +103,6 @@ def test_apply_pins_to_raw_screen_width():
assert out["screen_w"] == 2560
@pytest.mark.unit
def test_apply_pins_to_raw_fonts_list():
"""AP2 — list pin joined into comma-separated whitelist."""
out = _apply_pins_to_raw(_raw_baseline(), {"fonts": ["Arial", "Verdana"]})
assert out["font_whitelist"] == "Arial,Verdana"
@pytest.mark.unit
def test_apply_pins_to_raw_fonts_tuple():
"""AP3 — tuple pin is also accepted."""
out = _apply_pins_to_raw(_raw_baseline(), {"fonts": ("Arial",)})
assert out["font_whitelist"] == "Arial"
@pytest.mark.unit
def test_apply_pins_to_raw_fonts_string_raises():
"""AP4 — bare string is not a list/tuple, must raise."""
with pytest.raises(TypeError, match="list/tuple"):
_apply_pins_to_raw(_raw_baseline(), {"fonts": "Arial"})
@pytest.mark.unit
def test_apply_pins_to_raw_fonts_int_raises():
"""AP5 — int is also rejected."""
with pytest.raises(TypeError):
_apply_pins_to_raw(_raw_baseline(), {"fonts": 42})
@pytest.mark.unit
def test_apply_pins_to_raw_multiple_pins():
"""AP6 — multiple pins all land in raw."""
@ -158,7 +123,7 @@ def test_apply_pins_to_raw_returns_copy_not_mutation():
@pytest.mark.unit
def test_apply_pins_to_raw_unknown_key_silent():
"""AP8 — key not in `_PIN_TO_RAW` (and not 'fonts') is ignored.
"""AP8 — key not in `_PIN_TO_RAW` is ignored.
Validation happens upstream in `generate_profile`; the inner helper
guards defensively but does not raise.
@ -260,15 +225,6 @@ def test_generate_profile_is_frozen():
p.seed = 99 # type: ignore[misc]
@pytest.mark.unit
def test_generate_profile_fonts_is_list_of_strings():
"""GP11 — fonts is a non-empty list of stripped strings."""
p = generate_profile(seed=42)
assert isinstance(p.fonts, list)
assert len(p.fonts) > 0
assert all(isinstance(f, str) and f.strip() == f for f in p.fonts)
@pytest.mark.unit
def test_generate_profile_to_prefs_dict_flat_and_matches_raw():
"""GP12 — to_prefs_dict() returns a flat dict containing core sampler keys."""
@ -319,13 +275,6 @@ def test_generate_profile_pin_dark_theme_false():
assert p.dark_theme is False
@pytest.mark.unit
def test_generate_profile_pin_fonts_list_visible_on_profile():
"""fonts pin: list → joined raw string → split back to list on Profile."""
p = generate_profile(seed=42, pin={"fonts": ["Arial", "Verdana"]})
assert p.fonts == ["Arial", "Verdana"]
@pytest.mark.unit
def test_generate_profile_pin_gpu_renderer_propagates():
p = generate_profile(seed=42, pin={"gpu.renderer": "FORCED_RENDERER"})
@ -335,14 +284,13 @@ def test_generate_profile_pin_gpu_renderer_propagates():
@pytest.mark.unit
def test_generate_profile_pin_to_raw_keymap_complete():
"""Every dotted pin key (besides 'fonts') has a `_PIN_TO_RAW` mapping.
"""Every dotted pin key has a `_PIN_TO_RAW` mapping.
Guards against silently-ignored pins if someone adds a key to `_PIN_GROUPS`
but forgets the raw-key mapping.
"""
dotted = {f"{group}.{field}" for group, fields in _PIN_GROUPS.items()
for field in fields}
# 'dark_theme' is top-level and present in _PIN_TO_RAW; 'fonts' is handled
# specially and intentionally absent.
# 'dark_theme' is top-level and present in _PIN_TO_RAW.
missing = dotted - set(_PIN_TO_RAW.keys())
assert missing == set(), f"pin keys without raw mapping: {sorted(missing)}"

View file

@ -128,6 +128,16 @@ def test_clean_install_from_git_main(clean_venv: Path):
url = f"git+{REPO_URL}@{REV}"
_run([str(clean_venv), "-m", "pip", "install", url], timeout=600)
# Pin Playwright to the version the shipped binary's Juggler is built for.
# The wrapper's dependency is an open range, so an unpinned install in this
# venv silently drifts onto whatever pip resolves to. Upstream Playwright
# releases ship Juggler-protocol changes (e.g. Browser.setDefaultViewport in
# 1.61) the published binary does not speak, which breaks new_context. Force
# the blessed pin so this venv (reused by the launch test) tests the version
# users are expected to run, not a future incompatible release.
pin = (Path(__file__).resolve().parents[1] / "scripts" / "playwright_pin.txt").read_text().strip()
_run([str(clean_venv), "-m", "pip", "install", f"playwright=={pin}", "--quiet"], timeout=180)
# Importability check — catches missing __init__ exports, broken syntax,
# missing runtime deps.
out = _run(

View file

@ -1,11 +1,8 @@
"""Unit tests for invisible_playwright._fpforge._sampler.
Covers classify_gpu (decision-table over GPU strings), _screen_tier,
derive_font_prefs / derive_font_whitelist, and the public Forge / sample
entry points.
and the public Forge / sample entry points.
"""
import random
import pytest
from invisible_playwright._fpforge import _sampler
@ -14,8 +11,6 @@ from invisible_playwright._fpforge._sampler import (
_LOCKED,
_screen_tier,
classify_gpu,
derive_font_prefs,
derive_font_whitelist,
sample,
)
@ -213,68 +208,6 @@ def test_screen_tier_4200x2000_is_ultrawide_via_width_branch():
assert _screen_tier({"screen": {"w": 4200, "h": 2000}}) == "ultrawide"
# ── derive_font_prefs / derive_font_whitelist ───────────────────────────
@pytest.mark.unit
def test_derive_font_prefs_returns_whitelist_and_metrics_keys():
"""FP1 [HAPPY]: result has the two expected string keys."""
out = derive_font_prefs("integrated_modern", random.Random(42))
assert set(out.keys()) == {"whitelist", "metrics"}
assert isinstance(out["whitelist"], str)
assert isinstance(out["metrics"], str)
@pytest.mark.unit
def test_derive_font_prefs_core_fonts_always_present():
"""FP2 [ECP]: every core font name appears in whitelist regardless of class."""
out = derive_font_prefs("integrated_old", random.Random(0))
names = set(out["whitelist"].split(","))
for entry in _sampler._FONT_CORE:
assert entry["name"] in names
@pytest.mark.unit
def test_derive_font_prefs_deterministic_per_seed():
"""FP3 [ECP]: same gpu_class + same rng seed → identical result."""
a = derive_font_prefs("workstation", random.Random(7))
b = derive_font_prefs("workstation", random.Random(7))
assert a == b
@pytest.mark.unit
def test_derive_font_prefs_unknown_class_falls_back_to_integrated_modern():
"""FP4 [ECP]: gpu_class missing from CPT → uses integrated_modern row."""
fallback = derive_font_prefs("nonexistent", random.Random(123))
expected = derive_font_prefs("integrated_modern", random.Random(123))
assert fallback == expected
@pytest.mark.unit
def test_derive_font_prefs_metrics_and_whitelist_are_coherent():
"""FP5 [ECP]: every name in whitelist has a metrics entry and vice versa."""
out = derive_font_prefs("mid_range", random.Random(99))
wl_names = out["whitelist"].split(",")
metrics_names = [s.split("|", 1)[0] for s in out["metrics"].split(",")]
assert wl_names == metrics_names
@pytest.mark.unit
def test_derive_font_prefs_whitelist_alphabetically_sorted():
"""FP6 [ECP]: whitelist names are sorted (ordering invariant for stable dedup)."""
out = derive_font_prefs("high_end", random.Random(5))
names = out["whitelist"].split(",")
assert names == sorted(names)
@pytest.mark.unit
def test_derive_font_whitelist_legacy_shim_matches_dict_form():
"""FW1 [HAPPY]: legacy shim returns same string as dict['whitelist']."""
rng_a = random.Random(11)
rng_b = random.Random(11)
assert derive_font_whitelist("low_end", rng_a) == \
derive_font_prefs("low_end", rng_b)["whitelist"]
# ── Forge / sample ──────────────────────────────────────────────────────
# Keys the Forge.sample bundle must always contain. Builds on _LOCKED +
@ -290,7 +223,6 @@ _EXPECTED_KEYS = {
"av1_enabled", "webm_encoder_enabled",
"mediasource_webm", "mediasource_mp4", "webspeech_synth",
"storage_quota_mb", "dark_theme",
"font_whitelist", "font_metrics",
}
@ -377,15 +309,6 @@ def test_forge_sample_avail_h_defaults_to_h_minus_40_when_missing(monkeypatch):
assert out["screen_avail_h"] == 1080 - 40
@pytest.mark.unit
def test_forge_sample_includes_font_keys():
"""FS9 [ECP]: font_whitelist + font_metrics present and non-empty."""
out = sample(42)
assert out["font_whitelist"]
assert out["font_metrics"]
assert "," in out["font_whitelist"] # at least the core fonts joined
@pytest.mark.unit
def test_forge_seed_coercion_to_int():
"""FS extra: Forge(seed) coerces seed to int (e.g. float 42.7 → 42)."""

View file

@ -0,0 +1,63 @@
"""Regression guard: render-noise stays ACTIVE on high-entropy WebGL readbacks.
The near-uniform skip added 2026-06-18 (so fixed-hash reference checks on a solid
WebGL readback pass) must NOT disable noise on real fingerprint renders. A
high-entropy WebGL readback (>16 distinct colours) must still differ between two
seeds i.e. the per-seed gamma noise is applied. Pairs with
test_canvas_render_stealth.py (solid readback stays pure).
Kept in its own file: it launches its own short-lived browsers, so it must not run
alongside another module's shared browser (the Playwright sync API cannot nest).
Run: pytest tests/test_webgl_noise_active.py -m e2e -v
"""
from __future__ import annotations
import hashlib
import pytest
from invisible_playwright import InvisiblePlaywright
# A high-entropy render: 64 columns, each a distinct colour (>16 distinct → the
# near-uniform skip does NOT apply → noise must run).
_HIGH_ENTROPY_JS = """() => {
const c = document.createElement('canvas'); c.width = 64; c.height = 64;
const gl = c.getContext('webgl', {preserveDrawingBuffer: true});
if (!gl) return null;
gl.enable(gl.SCISSOR_TEST);
for (let k = 0; k < 64; k++) {
gl.scissor(k, 0, 1, 64);
gl.clearColor(k / 64, (63 - k) / 64, (k * 7 % 64) / 64, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
}
const buf = new Uint8Array(64 * 64 * 4);
gl.finish(); gl.readPixels(0, 0, 64, 64, gl.RGBA, gl.UNSIGNED_BYTE, buf);
return Array.from(buf);
}"""
def _render_hash(firefox_binary, seed: int):
with InvisiblePlaywright(
seed=seed, binary_path=firefox_binary, headless=True,
extra_prefs={"zoom.stealth.fpp.hw_seed": 1000 + seed},
) as b:
p = b.new_context().new_page()
p.goto("about:blank", timeout=30_000)
arr = p.evaluate(_HIGH_ENTROPY_JS)
if arr is None:
return None
return hashlib.sha256(bytes(arr)).hexdigest()
@pytest.mark.e2e
def test_high_entropy_webgl_still_noised_per_seed(firefox_binary):
"""Two different seeds → two different per-seed gamma curves → the high-entropy
readback hashes must differ. Identical hashes would mean the noise was skipped
on a real (non-uniform) render a regression of the uniform-skip scope."""
h1 = _render_hash(firefox_binary, 1)
h2 = _render_hash(firefox_binary, 2)
if h1 is None or h2 is None:
pytest.skip("webgl unavailable")
assert h1 != h2, \
"high-entropy WebGL readback identical across seeds → render-noise not applied"