Compare commits
84 commits
firefox-11
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa7277a1ef | ||
|
|
5026a98dec | ||
|
|
cafbbca82f | ||
|
|
e09cc07f69 | ||
|
|
a1b75d93e9 | ||
|
|
47a1044733 | ||
|
|
3f5a26a9e8 | ||
|
|
a0a0cc2369 | ||
|
|
029b678964 | ||
|
|
d3462cca47 | ||
|
|
17bab0cca4 | ||
|
|
3c018f7bbf | ||
|
|
310bb215df | ||
|
|
76e41e2789 | ||
|
|
d434ee4059 | ||
|
|
bb6305b439 | ||
|
|
6a48fb6022 | ||
|
|
c9853961d3 | ||
|
|
b93fc25a3f | ||
|
|
993ec95eb2 | ||
|
|
8695a6a4e0 | ||
|
|
1f087fd6d3 | ||
|
|
036076a3dd | ||
|
|
17d67e067c | ||
|
|
faec39c6ab | ||
|
|
4458fa585c | ||
|
|
163ac7f787 | ||
|
|
3d36af5ef6 | ||
|
|
a59d008a43 | ||
|
|
233f4b088f | ||
|
|
e1de4c3d96 | ||
|
|
daf84f78cd | ||
|
|
bf302701c6 | ||
|
|
dd59c41bb5 | ||
|
|
24ad6e4233 | ||
|
|
7914f3d006 | ||
|
|
050ec1aa61 | ||
|
|
07a812c1f2 | ||
|
|
1c942713aa | ||
|
|
acc5c27d5b | ||
|
|
4cbe475473 | ||
|
|
1b22e1bb4a | ||
|
|
9ad2b1eee6 | ||
|
|
dbffc52570 | ||
|
|
81849a32d2 | ||
|
|
e4c31427e3 | ||
|
|
f26df624c4 | ||
|
|
4f882b550f | ||
|
|
74ad6d0fce | ||
|
|
cfa33e8756 | ||
|
|
d85e407bbc | ||
|
|
475cb3f4fc | ||
|
|
eeddb8fcde | ||
|
|
7e06d60f04 | ||
|
|
92b374a497 | ||
|
|
fa869c52b8 | ||
|
|
7a42984158 | ||
|
|
95b1e47e0c | ||
|
|
62007c21d8 | ||
|
|
6cca6b3a69 | ||
|
|
3046ca17d3 | ||
|
|
2aa14d9b5b | ||
|
|
0b5edf4e35 | ||
|
|
0e6b0dddfe | ||
|
|
2184f6f3c2 | ||
|
|
d35ac458a7 | ||
|
|
567d921d5f | ||
|
|
0b54484b63 | ||
|
|
be940b0084 | ||
|
|
29df4e873c | ||
|
|
9b9e63972c | ||
|
|
168179eb03 | ||
|
|
02b5f397ad | ||
|
|
82eb22634e | ||
|
|
929c30219f | ||
|
|
01d56601ae | ||
|
|
b00bd74a9e | ||
|
|
64641db82e | ||
|
|
74b674516b | ||
|
|
2bc52f4a61 | ||
|
|
3c0efa2d4f | ||
|
|
8f4b20a19d | ||
|
|
6dcdc42c05 | ||
|
|
c34bc33686 |
2
.github/ISSUE_TEMPLATE/01-launch-failure.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
36
.github/ISSUE_TEMPLATE/03-stealth-detection.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
6
.github/ISSUE_TEMPLATE/config.yml
vendored
|
|
@ -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.
|
||||
|
|
|
|||
47
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
|
|
@ -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
|
||||
4
.github/workflows/e2e.yml
vendored
|
|
@ -19,6 +19,10 @@ name: e2e
|
|||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- 'docs/**'
|
||||
- 'LICENSE'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
|
|
|||
479
.github/workflows/release.yml
vendored
|
|
@ -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 }}
|
||||
214
.github/workflows/republish.yml
vendored
|
|
@ -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 }}
|
||||
111
.github/workflows/verify-assets.yml
vendored
|
|
@ -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 }}
|
||||
103
.github/workflows/verify-cloak.yml
vendored
|
|
@ -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
|
||||
10
CHANGELOG.md
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>
|
||||
|
||||
[](https://github.com/feder-cr/invisible_playwright/actions/workflows/tests.yml)
|
||||
[](LICENSE)
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://www.mozilla.org/firefox/)
|
||||
[](https://github.com/feder-cr/invisible_playwright/releases)
|
||||
[](https://github.com/feder-cr/invisible_playwright/stargazers)
|
||||
[](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>
|
||||
|
||||
[](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>
|
||||
|
||||

|
||||
|
||||
## 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>
|
||||
<a href="https://it.linkedin.com/in/federico-elia-5199951b6"><img src="docs/badges/linkedin.svg" alt="LinkedIn"></a>
|
||||
</p>
|
||||
|
|
|
|||
51
SECURITY.md
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
After Width: | Height: | Size: 106 KiB |
BIN
docs/banner-light.png
Normal file
|
After Width: | Height: | Size: 106 KiB |
BIN
docs/invisible_playwright-logo.png
Normal file
|
After Width: | Height: | Size: 87 KiB |
|
|
@ -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
|
|
@ -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]))
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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"}
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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}]
|
||||
}
|
||||
}
|
||||
|
|
@ -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}]
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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}
|
||||
]
|
||||
}
|
||||
|
|
@ -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}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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-10"
|
||||
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
67
tests/test_canvas_render_stealth.py
Normal 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).
|
||||
|
|
@ -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."
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)}"
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)."""
|
||||
|
|
|
|||
63
tests/test_webgl_noise_active.py
Normal 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"
|
||||