mirror of
https://github.com/feder-cr/invisible_playwright.git
synced 2026-06-22 09:18:06 +02:00
feat: initial public release
Stealthfox — a patched Firefox 150.0.1 for browser-fingerprint stealth,
shipped as a Playwright-compatible Python wrapper.
* Sync + async Stealthfox launcher (firefox_user_prefs, virtual desktop
on Windows, SOCKS5 auth via patched nsProtocolProxyService)
* fpforge: Bayesian fingerprint sampler over GPU / audio / fonts /
screen / TCP options / ~400 other navigator fields
* WebRTC stealth: srflx address swap, synthetic srflx fallback,
private-LAN host candidates — no real public IP leak via STUN
* GPU sandbox fix for FF150 alt-desktop regression
* Bezier-curve mouse motion baked into Juggler
Targets Windows x86_64 + Linux x86_64. Binary fetched on first run from
GitHub Release "firefox-1".
This commit is contained in:
commit
60e55491ea
51 changed files with 10967 additions and 0 deletions
22
src/stealthfox/__init__.py
Normal file
22
src/stealthfox/__init__.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
"""stealthfox — Playwright wrapper for a patched Firefox with stealth profile.
|
||||
|
||||
Quickstart:
|
||||
|
||||
from stealthfox import Stealthfox
|
||||
|
||||
with Stealthfox() as browser: # random seed
|
||||
page = browser.new_page()
|
||||
page.goto("https://example.com")
|
||||
|
||||
with Stealthfox(seed=42) as browser: # deterministic
|
||||
...
|
||||
|
||||
with Stealthfox(humanize=True) as browser: # human-like cursor motion
|
||||
page = browser.new_page()
|
||||
page.click("#submit") # expanded into a Bezier trajectory
|
||||
"""
|
||||
from .launcher import Stealthfox
|
||||
from .constants import BINARY_VERSION, FIREFOX_UPSTREAM_VERSION
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__all__ = ["Stealthfox", "BINARY_VERSION", "FIREFOX_UPSTREAM_VERSION", "__version__"]
|
||||
4
src/stealthfox/__main__.py
Normal file
4
src/stealthfox/__main__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
from .cli import main
|
||||
import sys
|
||||
|
||||
sys.exit(main())
|
||||
26
src/stealthfox/_fpforge/__init__.py
Normal file
26
src/stealthfox/_fpforge/__init__.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
"""Internal Bayesian fingerprint generator used by stealthfox.
|
||||
|
||||
Private module — do not import from user code. Use
|
||||
stealthfox.Stealthfox(seed=..., pin=...) instead.
|
||||
"""
|
||||
from .profile import (
|
||||
AudioProfile,
|
||||
CodecProfile,
|
||||
GPUProfile,
|
||||
HardwareProfile,
|
||||
Profile,
|
||||
ScreenProfile,
|
||||
WebGLProfile,
|
||||
generate_profile,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"generate_profile",
|
||||
"Profile",
|
||||
"GPUProfile",
|
||||
"ScreenProfile",
|
||||
"HardwareProfile",
|
||||
"AudioProfile",
|
||||
"CodecProfile",
|
||||
"WebGLProfile",
|
||||
]
|
||||
131
src/stealthfox/_fpforge/_network.py
Normal file
131
src/stealthfox/_fpforge/_network.py
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
# -*- 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) -> Dict[str, Any]:
|
||||
context: Dict[str, Any] = {}
|
||||
for node in self.nodes:
|
||||
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
|
||||
358
src/stealthfox/_fpforge/_sampler.py
Normal file
358
src/stealthfox/_fpforge/_sampler.py
Normal file
|
|
@ -0,0 +1,358 @@
|
|||
# -*- 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, 444 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
|
||||
|
||||
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"]
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# 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"
|
||||
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"]
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# 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) -> Dict[str, Any]:
|
||||
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 444 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()
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def sample(seed: int) -> Dict[str, Any]:
|
||||
"""Convenience: `Forge(seed).sample()`."""
|
||||
return Forge(seed).sample()
|
||||
193
src/stealthfox/_fpforge/data/cpt_audio_given_class.json
Normal file
193
src/stealthfox/_fpforge/data/cpt_audio_given_class.json
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
{
|
||||
"_meta": "audio (rate/latency/channels) given gpu_class",
|
||||
"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.6
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"rate": 44100,
|
||||
"latency": 40,
|
||||
"channels": 2
|
||||
},
|
||||
"prob": 0.25
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"rate": 48000,
|
||||
"latency": 25,
|
||||
"channels": 6
|
||||
},
|
||||
"prob": 0.15
|
||||
}
|
||||
],
|
||||
"low_end": [
|
||||
{
|
||||
"value": {
|
||||
"rate": 48000,
|
||||
"latency": 40,
|
||||
"channels": 2
|
||||
},
|
||||
"prob": 0.55
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"rate": 44100,
|
||||
"latency": 50,
|
||||
"channels": 2
|
||||
},
|
||||
"prob": 0.3
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"rate": 48000,
|
||||
"latency": 30,
|
||||
"channels": 6
|
||||
},
|
||||
"prob": 0.15
|
||||
}
|
||||
],
|
||||
"mid_range": [
|
||||
{
|
||||
"value": {
|
||||
"rate": 48000,
|
||||
"latency": 25,
|
||||
"channels": 2
|
||||
},
|
||||
"prob": 0.45
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"rate": 48000,
|
||||
"latency": 20,
|
||||
"channels": 6
|
||||
},
|
||||
"prob": 0.3
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"rate": 48000,
|
||||
"latency": 20,
|
||||
"channels": 8
|
||||
},
|
||||
"prob": 0.15
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"rate": 44100,
|
||||
"latency": 30,
|
||||
"channels": 2
|
||||
},
|
||||
"prob": 0.1
|
||||
}
|
||||
],
|
||||
"high_end": [
|
||||
{
|
||||
"value": {
|
||||
"rate": 48000,
|
||||
"latency": 15,
|
||||
"channels": 6
|
||||
},
|
||||
"prob": 0.3
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"rate": 48000,
|
||||
"latency": 15,
|
||||
"channels": 8
|
||||
},
|
||||
"prob": 0.3
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"rate": 48000,
|
||||
"latency": 15,
|
||||
"channels": 2
|
||||
},
|
||||
"prob": 0.2
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"rate": 96000,
|
||||
"latency": 15,
|
||||
"channels": 6
|
||||
},
|
||||
"prob": 0.1
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"rate": 96000,
|
||||
"latency": 15,
|
||||
"channels": 8
|
||||
},
|
||||
"prob": 0.1
|
||||
}
|
||||
],
|
||||
"workstation": [
|
||||
{
|
||||
"value": {
|
||||
"rate": 48000,
|
||||
"latency": 10,
|
||||
"channels": 8
|
||||
},
|
||||
"prob": 0.25
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"rate": 96000,
|
||||
"latency": 10,
|
||||
"channels": 8
|
||||
},
|
||||
"prob": 0.3
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"rate": 96000,
|
||||
"latency": 10,
|
||||
"channels": 6
|
||||
},
|
||||
"prob": 0.2
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"rate": 192000,
|
||||
"latency": 10,
|
||||
"channels": 8
|
||||
},
|
||||
"prob": 0.15
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"rate": 48000,
|
||||
"latency": 15,
|
||||
"channels": 6
|
||||
},
|
||||
"prob": 0.1
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
147
src/stealthfox/_fpforge/data/cpt_codec_given_class.json
Normal file
147
src/stealthfox/_fpforge/data/cpt_codec_given_class.json
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
{
|
||||
"_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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
295
src/stealthfox/_fpforge/data/cpt_fonts_optional_given_class.json
Normal file
295
src/stealthfox/_fpforge/data/cpt_fonts_optional_given_class.json
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
{
|
||||
"_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
|
||||
}
|
||||
}
|
||||
}
|
||||
50
src/stealthfox/_fpforge/data/cpt_hwc_given_class.json
Normal file
50
src/stealthfox/_fpforge/data/cpt_hwc_given_class.json
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
{
|
||||
"_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}
|
||||
]
|
||||
}
|
||||
}
|
||||
349
src/stealthfox/_fpforge/data/cpt_hwc_given_class_tier.json
Normal file
349
src/stealthfox/_fpforge/data/cpt_hwc_given_class_tier.json
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
{
|
||||
"_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.3
|
||||
},
|
||||
{
|
||||
"value": 12,
|
||||
"prob": 0.05
|
||||
}
|
||||
],
|
||||
"[\"integrated_modern\", \"budget\"]": [
|
||||
{
|
||||
"value": 4,
|
||||
"prob": 0.55
|
||||
},
|
||||
{
|
||||
"value": 6,
|
||||
"prob": 0.2
|
||||
},
|
||||
{
|
||||
"value": 8,
|
||||
"prob": 0.2
|
||||
},
|
||||
{
|
||||
"value": 12,
|
||||
"prob": 0.05
|
||||
}
|
||||
],
|
||||
"[\"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.1
|
||||
},
|
||||
{
|
||||
"value": 16,
|
||||
"prob": 0.05
|
||||
}
|
||||
],
|
||||
"[\"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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
17
src/stealthfox/_fpforge/data/cpt_intra_tier_given_class.json
Normal file
17
src/stealthfox/_fpforge/data/cpt_intra_tier_given_class.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"_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}]
|
||||
}
|
||||
}
|
||||
16
src/stealthfox/_fpforge/data/cpt_msaa_given_class.json
Normal file
16
src/stealthfox/_fpforge/data/cpt_msaa_given_class.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"_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}]
|
||||
}
|
||||
}
|
||||
305
src/stealthfox/_fpforge/data/cpt_msaa_given_class_screen.json
Normal file
305
src/stealthfox/_fpforge/data/cpt_msaa_given_class_screen.json
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
{
|
||||
"_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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
54
src/stealthfox/_fpforge/data/cpt_screen_given_class.json
Normal file
54
src/stealthfox/_fpforge/data/cpt_screen_given_class.json
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
{
|
||||
"_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}
|
||||
]
|
||||
}
|
||||
}
|
||||
761
src/stealthfox/_fpforge/data/cpt_screen_given_class_tier.json
Normal file
761
src/stealthfox/_fpforge/data/cpt_screen_given_class_tier.json
Normal file
|
|
@ -0,0 +1,761 @@
|
|||
{
|
||||
"_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": 1366,
|
||||
"h": 768,
|
||||
"aw": 1366,
|
||||
"ah": 728,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.3
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"w": 1920,
|
||||
"h": 1080,
|
||||
"aw": 1920,
|
||||
"ah": 1040,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.65
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"w": 1600,
|
||||
"h": 900,
|
||||
"aw": 1600,
|
||||
"ah": 860,
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
60
src/stealthfox/_fpforge/data/cpt_storage_given_class.json
Normal file
60
src/stealthfox/_fpforge/data/cpt_storage_given_class.json
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
{
|
||||
"_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}
|
||||
]
|
||||
}
|
||||
}
|
||||
305
src/stealthfox/_fpforge/data/cpt_storage_given_class_tier.json
Normal file
305
src/stealthfox/_fpforge/data/cpt_storage_given_class_tier.json
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
{
|
||||
"_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.3
|
||||
},
|
||||
{
|
||||
"value": 1000000,
|
||||
"prob": 0.05
|
||||
}
|
||||
],
|
||||
"[\"integrated_modern\", \"budget\"]": [
|
||||
{
|
||||
"value": 64000,
|
||||
"prob": 0.2
|
||||
},
|
||||
{
|
||||
"value": 128000,
|
||||
"prob": 0.3
|
||||
},
|
||||
{
|
||||
"value": 256000,
|
||||
"prob": 0.3
|
||||
},
|
||||
{
|
||||
"value": 500000,
|
||||
"prob": 0.2
|
||||
}
|
||||
],
|
||||
"[\"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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
650
src/stealthfox/_fpforge/data/ff_win_distributions.json
Normal file
650
src/stealthfox/_fpforge/data/ff_win_distributions.json
Normal file
|
|
@ -0,0 +1,650 @@
|
|||
{
|
||||
"_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
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
670
src/stealthfox/_fpforge/data/font_pool.json
Normal file
670
src/stealthfox/_fpforge/data/font_pool.json
Normal file
|
|
@ -0,0 +1,670 @@
|
|||
{
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
17
src/stealthfox/_fpforge/data/prior_audio.json
Normal file
17
src/stealthfox/_fpforge/data/prior_audio.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"_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}
|
||||
]
|
||||
}
|
||||
14
src/stealthfox/_fpforge/data/priors_independent.json
Normal file
14
src/stealthfox/_fpforge/data/priors_independent.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"_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}
|
||||
]
|
||||
}
|
||||
}
|
||||
1902
src/stealthfox/_fpforge/data/webgl_renderer_pool.json
Normal file
1902
src/stealthfox/_fpforge/data/webgl_renderer_pool.json
Normal file
File diff suppressed because it is too large
Load diff
259
src/stealthfox/_fpforge/profile.py
Normal file
259
src/stealthfox/_fpforge/profile.py
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
"""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
|
||||
_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 `stealthfox.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) -> 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 stealthfox API):
|
||||
|
||||
from stealthfox import Stealthfox
|
||||
|
||||
with Stealthfox(
|
||||
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)
|
||||
|
||||
raw = _sample_raw(int(seed))
|
||||
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"]),
|
||||
_raw=raw,
|
||||
)
|
||||
228
src/stealthfox/_headless.py
Normal file
228
src/stealthfox/_headless.py
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
"""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* on a
|
||||
virtual display gives us the real rendering pipeline while keeping the
|
||||
windows off the user's screen.
|
||||
|
||||
Linux: spawns its own ``Xvfb`` instance, points ``DISPLAY`` at it.
|
||||
Windows: creates a hidden desktop via ``CreateDesktop`` and binds the
|
||||
calling thread to it, so Playwright's child processes inherit it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import secrets
|
||||
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 Stealthfox 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(
|
||||
"stealthfox 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
|
||||
|
||||
|
||||
class _WindowsVirtualDesktop:
|
||||
"""A hidden Windows desktop the calling thread is bound to.
|
||||
|
||||
Playwright's child processes (node driver → firefox.exe) inherit the
|
||||
desktop because their ``STARTUPINFO.lpDesktop`` is NULL — Windows uses
|
||||
the calling thread's desktop in that case.
|
||||
|
||||
pywin32 ships ``CreateDesktop`` in ``win32service`` but doesn't expose
|
||||
``SetThreadDesktop`` / ``GetThreadDesktop`` as module functions. We
|
||||
call them directly via ctypes against ``user32.dll``.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._desktop = None # PyHDESK from win32service.CreateDesktop
|
||||
self._original_handle = 0 # raw HDESK int of the previous desktop
|
||||
|
||||
def start(self) -> None:
|
||||
try:
|
||||
import win32con # type: ignore
|
||||
import win32service # type: ignore
|
||||
except ImportError as e:
|
||||
raise RuntimeError(
|
||||
"stealthfox headless=True on Windows requires pywin32. "
|
||||
"Install it: pip install pywin32"
|
||||
) from e
|
||||
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
user32 = ctypes.windll.user32
|
||||
kernel32 = ctypes.windll.kernel32
|
||||
|
||||
# Save the current desktop handle so we can restore it on stop().
|
||||
get_thread_desktop = user32.GetThreadDesktop
|
||||
get_thread_desktop.argtypes = [wintypes.DWORD]
|
||||
get_thread_desktop.restype = wintypes.HANDLE
|
||||
self._original_handle = get_thread_desktop(kernel32.GetCurrentThreadId())
|
||||
|
||||
name = f"sf_{secrets.token_hex(4)}"
|
||||
self._desktop = win32service.CreateDesktop(
|
||||
name, 0, win32con.GENERIC_ALL, None
|
||||
)
|
||||
|
||||
# Bind the calling thread to the new desktop. Children spawned
|
||||
# afterwards (Playwright driver → firefox.exe) inherit it because
|
||||
# their STARTUPINFO.lpDesktop is NULL.
|
||||
set_thread_desktop = user32.SetThreadDesktop
|
||||
set_thread_desktop.argtypes = [wintypes.HANDLE]
|
||||
set_thread_desktop.restype = wintypes.BOOL
|
||||
if not set_thread_desktop(int(self._desktop)):
|
||||
err = ctypes.get_last_error()
|
||||
raise RuntimeError(
|
||||
f"SetThreadDesktop failed (GetLastError={err}). "
|
||||
"The thread cannot have any windows or hooks; close them first."
|
||||
)
|
||||
|
||||
def stop(self) -> None:
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
user32 = ctypes.windll.user32
|
||||
|
||||
if self._original_handle:
|
||||
try:
|
||||
set_thread_desktop = user32.SetThreadDesktop
|
||||
set_thread_desktop.argtypes = [wintypes.HANDLE]
|
||||
set_thread_desktop.restype = wintypes.BOOL
|
||||
set_thread_desktop(self._original_handle)
|
||||
except Exception:
|
||||
pass
|
||||
self._original_handle = 0
|
||||
|
||||
if self._desktop is not None:
|
||||
try:
|
||||
self._desktop.CloseDesktop()
|
||||
except Exception:
|
||||
pass
|
||||
self._desktop = None
|
||||
|
||||
|
||||
def make_virtual_display():
|
||||
"""Return a started/stoppable virtual-display object for this platform.
|
||||
|
||||
Stealthfox supports Windows x86_64 and Linux x86_64 only.
|
||||
"""
|
||||
if sys.platform == "win32":
|
||||
return _WindowsVirtualDesktop()
|
||||
if sys.platform.startswith("linux"):
|
||||
return _LinuxVirtualDisplay()
|
||||
raise RuntimeError(
|
||||
f"stealthfox supports Windows and Linux only (got {sys.platform!r})"
|
||||
)
|
||||
|
||||
|
||||
def _binary_on_path(name: str) -> bool:
|
||||
import shutil
|
||||
return shutil.which(name) is not None
|
||||
56
src/stealthfox/_proxy.py
Normal file
56
src/stealthfox/_proxy.py
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
"""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
|
||||
178
src/stealthfox/async_api.py
Normal file
178
src/stealthfox/async_api.py
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
"""Async Playwright façade — mirrors sync_api but with async/await."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import secrets
|
||||
from typing import Any, Dict, Optional, Union
|
||||
|
||||
from playwright.async_api import Browser, Playwright, async_playwright
|
||||
|
||||
from ._fpforge import Profile, generate_profile
|
||||
from ._headless import make_virtual_display
|
||||
from ._proxy import configure_proxy as _configure_proxy_shared
|
||||
from .download import ensure_binary
|
||||
from .launcher import _CHROME_H, _CHROME_W, _TASKBAR_H, _tz_env
|
||||
from .prefs import translate_profile_to_prefs
|
||||
|
||||
|
||||
def _patch_new_page_sleep(ctx: Any) -> None:
|
||||
"""Wrap ctx.new_page() to add a brief settle after tab creation.
|
||||
|
||||
FF150 with Fission emits an about:newtab navigation ~100ms after a tab
|
||||
is created. If goto() is called immediately, it races with that internal
|
||||
navigation and raises "Navigation interrupted by about:newtab". A short
|
||||
sleep breaks the race without requiring every call-site to know about it.
|
||||
"""
|
||||
original_new_page = ctx.new_page
|
||||
|
||||
async def patched_new_page(**kw):
|
||||
page = await original_new_page(**kw)
|
||||
await asyncio.sleep(0.4)
|
||||
return page
|
||||
|
||||
ctx.new_page = patched_new_page # type: ignore[assignment]
|
||||
|
||||
|
||||
class Stealthfox:
|
||||
"""Async context manager — see stealthfox.Stealthfox for the sync variant."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
seed: Optional[int] = None,
|
||||
*,
|
||||
pin: Optional[Dict[str, Any]] = None,
|
||||
headless: bool = False,
|
||||
proxy: Optional[Dict[str, str]] = None,
|
||||
extra_args: Optional[list[str]] = None,
|
||||
humanize: Union[bool, float] = True,
|
||||
locale: str = "en-US",
|
||||
timezone: str = "",
|
||||
extra_prefs: Optional[Dict[str, Any]] = None,
|
||||
binary_path: Optional[str] = None,
|
||||
) -> None:
|
||||
# See sync launcher: `zoom.stealth.fpp.hw_seed` is int32_t — clamp.
|
||||
self.seed: int = int(seed) if seed is not None else secrets.randbits(31)
|
||||
self._pin = pin
|
||||
self._headless = headless
|
||||
self._proxy = proxy
|
||||
self._extra_args = list(extra_args or [])
|
||||
self._humanize = humanize
|
||||
self._locale = locale
|
||||
self._timezone = timezone
|
||||
self._extra_prefs = extra_prefs
|
||||
self._binary_path = binary_path
|
||||
self._profile: Profile = generate_profile(self.seed, pin=self._pin)
|
||||
self._pw: Optional[Playwright] = None
|
||||
self._browser: Optional[Browser] = None
|
||||
self._virtual_display: Any = None
|
||||
|
||||
async def __aenter__(self) -> Browser:
|
||||
import sys as _sys
|
||||
executable = self._binary_path or ensure_binary()
|
||||
prefs = translate_profile_to_prefs(
|
||||
self._profile,
|
||||
locale=self._locale,
|
||||
timezone=self._timezone,
|
||||
extra_prefs=self._extra_prefs,
|
||||
virtual_display=bool(self._headless and _sys.platform == "win32"),
|
||||
)
|
||||
prefs["stealthfox.humanize"] = bool(self._humanize)
|
||||
if self._humanize:
|
||||
cap = 1.5 if self._humanize is True else float(self._humanize)
|
||||
prefs["stealthfox.humanize.maxTime"] = str(cap)
|
||||
playwright_proxy = _configure_proxy_shared(self._proxy, prefs)
|
||||
pw_headless = self._resolve_headless()
|
||||
env = self._build_env()
|
||||
try:
|
||||
self._pw = await async_playwright().start()
|
||||
self._browser = await self._pw.firefox.launch(
|
||||
executable_path=str(executable),
|
||||
headless=pw_headless,
|
||||
firefox_user_prefs=prefs,
|
||||
proxy=playwright_proxy,
|
||||
args=self._extra_args,
|
||||
env=env,
|
||||
)
|
||||
except BaseException:
|
||||
await self._teardown()
|
||||
raise
|
||||
self._patch_new_context_defaults(self._browser)
|
||||
return self._browser
|
||||
|
||||
def _patch_new_context_defaults(self, browser: Browser) -> None:
|
||||
original = browser.new_context
|
||||
defaults = self._default_context_kwargs()
|
||||
|
||||
async def patched(**kw):
|
||||
merged = dict(defaults)
|
||||
merged.update(kw)
|
||||
ctx = await original(**merged)
|
||||
_patch_new_page_sleep(ctx)
|
||||
return ctx
|
||||
|
||||
browser.new_context = patched # type: ignore[assignment]
|
||||
|
||||
def _default_context_kwargs(self) -> Dict[str, Any]:
|
||||
p = self._profile
|
||||
kwargs: Dict[str, Any] = {
|
||||
"viewport": {"width": p.screen.width - _CHROME_W,
|
||||
"height": p.screen.height - _TASKBAR_H - _CHROME_H},
|
||||
"screen": {"width": p.screen.width, "height": p.screen.height},
|
||||
"device_scale_factor": p.screen.dpr,
|
||||
"color_scheme": "dark" if p.dark_theme else "light",
|
||||
}
|
||||
# Pass timezone via Playwright per-realm override (works for every
|
||||
# IANA name, including no-DST zones that Windows ICU silently drops
|
||||
# on the global pref path).
|
||||
if self._timezone:
|
||||
kwargs["timezone_id"] = self._timezone
|
||||
if self._locale:
|
||||
kwargs["locale"] = self._locale
|
||||
return kwargs
|
||||
|
||||
async def __aexit__(self, *exc: Any) -> None:
|
||||
await self._teardown()
|
||||
|
||||
async def _teardown(self) -> None:
|
||||
if self._browser is not None:
|
||||
try:
|
||||
await self._browser.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._browser = None
|
||||
if self._pw is not None:
|
||||
try:
|
||||
await self._pw.stop()
|
||||
except Exception:
|
||||
pass
|
||||
self._pw = None
|
||||
if self._virtual_display is not None:
|
||||
try:
|
||||
self._virtual_display.stop()
|
||||
except Exception:
|
||||
pass
|
||||
self._virtual_display = None
|
||||
|
||||
def _build_env(self) -> Dict[str, str]:
|
||||
import os as _os
|
||||
env = _os.environ.copy()
|
||||
if self._timezone:
|
||||
env["TZ"] = _tz_env(self._timezone)
|
||||
# Propagate STEALTHFOX_WEBRTC_PUBLIC_IP if the process set it — read
|
||||
# by nICEr's nr_stealth_bridge to inject a synthetic srflx candidate
|
||||
# matching the proxy egress IP. This avoids the StaticPref IPC
|
||||
# propagation timing issue between parent and socket processes.
|
||||
if _os.environ.get("STEALTHFOX_WEBRTC_PUBLIC_IP"):
|
||||
env["STEALTHFOX_WEBRTC_PUBLIC_IP"] = _os.environ["STEALTHFOX_WEBRTC_PUBLIC_IP"]
|
||||
return env
|
||||
|
||||
def _resolve_headless(self) -> bool:
|
||||
if not self._headless:
|
||||
return False
|
||||
vd = make_virtual_display()
|
||||
vd.start()
|
||||
self._virtual_display = vd
|
||||
return False
|
||||
|
||||
|
||||
__all__ = ["Stealthfox"]
|
||||
68
src/stealthfox/cli.py
Normal file
68
src/stealthfox/cli.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
"""Command-line interface for stealthfox."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
from . import __version__
|
||||
from .constants import BINARY_VERSION, FIREFOX_UPSTREAM_VERSION
|
||||
from .download import cache_root, ensure_binary
|
||||
|
||||
|
||||
def _cmd_fetch(_args: argparse.Namespace) -> int:
|
||||
path = ensure_binary()
|
||||
print(path)
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_path(_args: argparse.Namespace) -> int:
|
||||
try:
|
||||
path = ensure_binary()
|
||||
except Exception as e:
|
||||
print(f"error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
print(path)
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_version(_args: argparse.Namespace) -> int:
|
||||
print(f"stealthfox {__version__}")
|
||||
print(f"BINARY_VERSION={BINARY_VERSION} (Firefox {FIREFOX_UPSTREAM_VERSION})")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_clear_cache(_args: argparse.Namespace) -> int:
|
||||
root = cache_root()
|
||||
if root.exists():
|
||||
shutil.rmtree(root)
|
||||
print(f"removed: {root}")
|
||||
else:
|
||||
print(f"nothing to remove: {root}")
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser(prog="stealthfox", description="stealthfox CLI")
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
sub.add_parser("fetch", help="download the patched Firefox binary")
|
||||
sub.add_parser("path", help="print the absolute path to the cached binary")
|
||||
sub.add_parser("version", help="print wrapper and binary versions")
|
||||
sub.add_parser("clear-cache", help="remove all cached binaries")
|
||||
return p
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
dispatch = {
|
||||
"fetch": _cmd_fetch,
|
||||
"path": _cmd_path,
|
||||
"version": _cmd_version,
|
||||
"clear-cache": _cmd_clear_cache,
|
||||
}
|
||||
return dispatch[args.cmd](args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
48
src/stealthfox/constants.py
Normal file
48
src/stealthfox/constants.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
"""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-1"
|
||||
|
||||
# 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")
|
||||
machine: platform.machine() ("AMD64", "x86_64", ...)
|
||||
"""
|
||||
pk = platform_key.lower()
|
||||
m = machine.lower()
|
||||
if m in {"amd64", "x86_64"}:
|
||||
arch = "x86_64"
|
||||
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"
|
||||
raise NotImplementedError(f"unsupported platform: {platform_key}")
|
||||
|
||||
|
||||
# Binary entry point relative path inside the extracted archive root.
|
||||
BINARY_ENTRY_REL = {
|
||||
"win32": "firefox.exe",
|
||||
"linux": "firefox",
|
||||
}
|
||||
|
||||
# GitHub release URL template. The "TODO" owner is resolved at publication time.
|
||||
RELEASE_URL_TEMPLATE = (
|
||||
"https://github.com/feder-cr/stealthfox/releases/download/{tag}/{asset}"
|
||||
)
|
||||
1846
src/stealthfox/data/font-map.json
Normal file
1846
src/stealthfox/data/font-map.json
Normal file
File diff suppressed because it is too large
Load diff
151
src/stealthfox/download.py
Normal file
151
src/stealthfox/download.py
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
"""Download and cache the patched Firefox binary from GitHub Releases."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import platformdirs
|
||||
import requests
|
||||
|
||||
from .constants import (
|
||||
ARCHIVE_NAME,
|
||||
BINARY_ENTRY_REL,
|
||||
BINARY_VERSION,
|
||||
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("stealthfox"))
|
||||
|
||||
|
||||
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:
|
||||
out[parts[-1]] = 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 ensure_binary(version: str = BINARY_VERSION) -> Path:
|
||||
"""Return a path to a runnable Firefox executable. Download if needed."""
|
||||
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 not entry.exists():
|
||||
raise RuntimeError(f"binary not found after extraction: {entry}")
|
||||
return entry
|
||||
307
src/stealthfox/launcher.py
Normal file
307
src/stealthfox/launcher.py
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
"""Sync Playwright launcher for stealthfox."""
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from typing import Any, Dict, Optional, Union
|
||||
|
||||
from playwright.sync_api import Browser, Playwright, sync_playwright
|
||||
|
||||
from ._fpforge import Profile, generate_profile
|
||||
from ._headless import make_virtual_display
|
||||
from ._proxy import configure_proxy as _configure_proxy_shared
|
||||
from .download import ensure_binary
|
||||
from .prefs import translate_profile_to_prefs
|
||||
|
||||
|
||||
def _patch_sync_new_page_sleep(ctx: Any) -> None:
|
||||
"""Wrap ctx.new_page() to add a brief settle after tab creation.
|
||||
|
||||
FF150 with Fission emits an about:newtab navigation ~100ms after a tab
|
||||
is created. If goto() is called immediately, it races with that internal
|
||||
navigation and raises "Navigation interrupted by about:newtab". A short
|
||||
sleep breaks the race without requiring every call-site to know about it.
|
||||
"""
|
||||
import time as _time
|
||||
original_new_page = ctx.new_page
|
||||
|
||||
def patched_new_page(**kw):
|
||||
page = original_new_page(**kw)
|
||||
_time.sleep(0.4)
|
||||
return page
|
||||
|
||||
ctx.new_page = patched_new_page # type: ignore[assignment]
|
||||
|
||||
|
||||
# Window-chrome and taskbar offsets measured empirically on a headed
|
||||
# Firefox 150 (no compositor). Used to derive the default new_context
|
||||
# viewport so it fits inside the spoofed screen without out-of-bounds.
|
||||
_CHROME_W = 14
|
||||
_CHROME_H = 91
|
||||
_TASKBAR_H = 40
|
||||
|
||||
# IANA → POSIX TZ mapping. Linux glibc accepts IANA names directly via
|
||||
# /usr/share/zoneinfo, but Windows MSVCRT only understands the POSIX form
|
||||
# ("EST5EDT") — convert here so ``TZ`` works on both platforms when the
|
||||
# binary runs on Windows. Common US zones cover the vast majority of
|
||||
# residential proxies; everything else falls through to its IANA name.
|
||||
_IANA_TO_POSIX_TZ = {
|
||||
"America/New_York": "EST5EDT",
|
||||
"America/Detroit": "EST5EDT",
|
||||
"America/Indiana/Indianapolis": "EST5EDT",
|
||||
"America/Kentucky/Louisville": "EST5EDT",
|
||||
"America/Chicago": "CST6CDT",
|
||||
"America/Denver": "MST7MDT",
|
||||
"America/Los_Angeles": "PST8PDT",
|
||||
# Arizona (except Navajo Nation) does NOT observe DST. Mapping it to
|
||||
# MST7MDT made libc apply DST → Date.getTimezoneOffset() returned -360
|
||||
# in summer (Denver-like) instead of -420 (true Phoenix), and FP Pro
|
||||
# deduced vpn_origin_timezone="America/Denver" → timezone_mismatch.
|
||||
"America/Phoenix": "MST7",
|
||||
"America/Anchorage": "AKST9AKDT",
|
||||
# Hawaii does not observe DST.
|
||||
"Pacific/Honolulu": "HST10",
|
||||
}
|
||||
|
||||
|
||||
def _tz_env(timezone: str) -> str:
|
||||
"""Return the value to set in ``TZ`` for the given IANA zone."""
|
||||
return _IANA_TO_POSIX_TZ.get(timezone, timezone)
|
||||
|
||||
|
||||
class Stealthfox:
|
||||
"""Context manager launching a patched Firefox with a deterministic profile.
|
||||
|
||||
Usage:
|
||||
|
||||
from stealthfox import Stealthfox
|
||||
|
||||
# random seed (different fingerprint each call)
|
||||
with Stealthfox() as browser:
|
||||
page = browser.new_page()
|
||||
page.goto("https://example.com")
|
||||
|
||||
# explicit seed → same profile every time
|
||||
with Stealthfox(seed=42) as browser:
|
||||
...
|
||||
|
||||
# human-like cursor motion (Bezier trajectory on every mousemove)
|
||||
with Stealthfox(humanize=True) as browser:
|
||||
...
|
||||
|
||||
Optional ``pin`` forces specific fingerprint fields while the rest still
|
||||
varies with ``seed``::
|
||||
|
||||
with Stealthfox(seed=42, pin={"screen.width": 2560}) as browser:
|
||||
...
|
||||
|
||||
After construction, the chosen seed is available as ``self.seed`` — useful
|
||||
to reproduce a random run later.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
seed: Optional[int] = None,
|
||||
*,
|
||||
pin: Optional[Dict[str, Any]] = None,
|
||||
headless: bool = False,
|
||||
proxy: Optional[Dict[str, str]] = None,
|
||||
extra_args: Optional[list[str]] = None,
|
||||
humanize: Union[bool, float] = True,
|
||||
locale: str = "en-US",
|
||||
timezone: str = "",
|
||||
extra_prefs: Optional[Dict[str, Any]] = None,
|
||||
binary_path: Optional[str] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Args:
|
||||
seed: Integer seed driving the Bayesian fingerprint sampler.
|
||||
Same seed → same fingerprint. ``None`` = fresh random.
|
||||
pin: Force specific fingerprint fields (see docs/pinning.md).
|
||||
headless: When ``True``, browser renders on a hidden virtual
|
||||
display (Xvfb on Linux, ``CreateDesktop`` on Windows) so
|
||||
Firefox stays in *headed* mode (real rendering pipeline,
|
||||
coherent fingerprint) without showing windows.
|
||||
proxy: ``{"server": "...", "username": "...", "password": "..."}``.
|
||||
``socks5://`` / ``socks4://`` go through the patched
|
||||
``nsProtocolProxyService``; ``http(s)://`` go through
|
||||
Playwright's own ``proxy=`` kwarg.
|
||||
extra_args: Extra command-line args forwarded to Firefox.
|
||||
humanize: Every mouse move is expanded by the patched Juggler
|
||||
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``.
|
||||
timezone: IANA timezone (e.g. ``"America/New_York"``). Empty
|
||||
means use the host TZ.
|
||||
extra_prefs: Optional dict of Firefox prefs overlayed on top
|
||||
of the generated profile — useful for niche tweaks
|
||||
without monkey-patching the package.
|
||||
"""
|
||||
# Constrain to int31 — Firefox's `zoom.stealth.fpp.hw_seed` and
|
||||
# related stealth prefs are declared as ``int32_t`` in
|
||||
# ``StaticPrefList.yaml``. A 32-bit seed risks the high bit being
|
||||
# interpreted as negative on the C++ side, where the noise hooks
|
||||
# bail out on ``seed <= 0`` — which produces bit-identical audio
|
||||
# / canvas fingerprints across half the sessions.
|
||||
self.seed: int = int(seed) if seed is not None else secrets.randbits(31)
|
||||
self._pin = pin
|
||||
self._headless = headless
|
||||
self._proxy = proxy
|
||||
self._extra_args = list(extra_args or [])
|
||||
self._humanize = humanize
|
||||
self._locale = locale
|
||||
self._timezone = timezone
|
||||
self._extra_prefs = extra_prefs
|
||||
self._binary_path = binary_path
|
||||
self._profile: Profile = generate_profile(self.seed, pin=self._pin)
|
||||
self._pw: Optional[Playwright] = None
|
||||
self._browser: Optional[Browser] = None
|
||||
self._virtual_display: Any = None
|
||||
|
||||
def __enter__(self) -> Browser:
|
||||
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()
|
||||
|
||||
try:
|
||||
self._pw = sync_playwright().start()
|
||||
self._browser = self._pw.firefox.launch(
|
||||
executable_path=str(executable),
|
||||
headless=pw_headless,
|
||||
firefox_user_prefs=prefs,
|
||||
proxy=playwright_proxy,
|
||||
args=self._extra_args,
|
||||
env=env,
|
||||
)
|
||||
except BaseException:
|
||||
# Python doesn't call __exit__ when __enter__ raises — clean up
|
||||
# the virtual display + Playwright manually so we don't leak Xvfb
|
||||
# / desktop handles into the user's process.
|
||||
self._teardown()
|
||||
raise
|
||||
self._patch_new_context_defaults(self._browser)
|
||||
return self._browser
|
||||
|
||||
def _patch_new_context_defaults(self, browser: Browser) -> None:
|
||||
"""Wrap ``browser.new_context`` so its defaults derive from the
|
||||
profile (viewport, screen, DPR, color-scheme). Users get a
|
||||
coherent context for free; explicit kwargs still override.
|
||||
"""
|
||||
original = browser.new_context
|
||||
defaults = self._default_context_kwargs()
|
||||
|
||||
def patched(**kw):
|
||||
merged = dict(defaults)
|
||||
merged.update(kw) # user-supplied wins
|
||||
ctx = original(**merged)
|
||||
_patch_sync_new_page_sleep(ctx)
|
||||
return ctx
|
||||
|
||||
browser.new_context = patched # type: ignore[assignment]
|
||||
|
||||
def _default_context_kwargs(self) -> Dict[str, Any]:
|
||||
p = self._profile
|
||||
kwargs: Dict[str, Any] = {
|
||||
"viewport": {"width": p.screen.width - _CHROME_W,
|
||||
"height": p.screen.height - _TASKBAR_H - _CHROME_H},
|
||||
"screen": {"width": p.screen.width, "height": p.screen.height},
|
||||
"device_scale_factor": p.screen.dpr,
|
||||
"color_scheme": "dark" if p.dark_theme else "light",
|
||||
}
|
||||
# Pass timezone via Playwright's per-realm override (docShell.overrideTimezone
|
||||
# → JS::SetRealmTimezoneOverride). The juggler.timezone.override pref path
|
||||
# uses JS::SetTimeZoneOverride globally, which is broken on Windows ICU for
|
||||
# no-DST IANA names (America/Phoenix, Pacific/Honolulu, ...) — those silently
|
||||
# fall back to the host system TZ. The per-realm path works for every zone.
|
||||
if self._timezone:
|
||||
kwargs["timezone_id"] = self._timezone
|
||||
if self._locale:
|
||||
kwargs["locale"] = self._locale
|
||||
return kwargs
|
||||
|
||||
def __exit__(self, *exc: Any) -> None:
|
||||
self._teardown()
|
||||
|
||||
def _teardown(self) -> None:
|
||||
if self._browser is not None:
|
||||
try:
|
||||
self._browser.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._browser = None
|
||||
if self._pw is not None:
|
||||
try:
|
||||
self._pw.stop()
|
||||
except Exception:
|
||||
pass
|
||||
self._pw = None
|
||||
if self._virtual_display is not None:
|
||||
try:
|
||||
self._virtual_display.stop()
|
||||
except Exception:
|
||||
pass
|
||||
self._virtual_display = None
|
||||
|
||||
# ── helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
def _build_prefs(self) -> Dict[str, Any]:
|
||||
"""Fingerprint prefs plus humanize toggle (always set explicitly)."""
|
||||
import sys as _sys
|
||||
prefs = translate_profile_to_prefs(
|
||||
self._profile,
|
||||
locale=self._locale,
|
||||
timezone=self._timezone,
|
||||
extra_prefs=self._extra_prefs,
|
||||
virtual_display=bool(self._headless and _sys.platform == "win32"),
|
||||
)
|
||||
prefs["stealthfox.humanize"] = bool(self._humanize)
|
||||
if self._humanize:
|
||||
prefs["stealthfox.humanize.maxTime"] = str(self._humanize_max_seconds())
|
||||
return prefs
|
||||
|
||||
def _build_env(self) -> Dict[str, str]:
|
||||
"""Env vars passed to the Firefox subprocess.
|
||||
|
||||
``TZ`` tunes the libc clock the content process reads for
|
||||
``Date`` / ``Intl.DateTimeFormat`` so the JS-visible timezone
|
||||
matches ``self._timezone`` regardless of the host TZ.
|
||||
``STEALTHFOX_WEBRTC_PUBLIC_IP`` is propagated when the calling
|
||||
process has set it — read by nICEr's nr_stealth_bridge to inject
|
||||
a synthetic srflx candidate matching the proxy egress IP, avoiding
|
||||
the StaticPref IPC propagation timing issue between parent and
|
||||
socket processes.
|
||||
"""
|
||||
import os as _os
|
||||
env = _os.environ.copy()
|
||||
if self._timezone:
|
||||
env["TZ"] = _tz_env(self._timezone)
|
||||
# Propagate STEALTHFOX_WEBRTC_PUBLIC_IP if the process set it — read
|
||||
# by nICEr's nr_stealth_bridge to inject a synthetic srflx candidate
|
||||
# matching the proxy egress IP. This avoids the StaticPref IPC
|
||||
# propagation timing issue between parent and socket processes.
|
||||
if _os.environ.get("STEALTHFOX_WEBRTC_PUBLIC_IP"):
|
||||
env["STEALTHFOX_WEBRTC_PUBLIC_IP"] = _os.environ["STEALTHFOX_WEBRTC_PUBLIC_IP"]
|
||||
return env
|
||||
|
||||
def _resolve_headless(self) -> bool:
|
||||
"""Translate the user's ``headless`` flag.
|
||||
|
||||
When ``True``, we keep Firefox in headed mode (real rendering
|
||||
pipeline → coherent fingerprint) and hide the windows on a fresh
|
||||
Xvfb (Linux) or hidden Windows desktop.
|
||||
"""
|
||||
if not self._headless:
|
||||
return False
|
||||
vd = make_virtual_display()
|
||||
vd.start()
|
||||
self._virtual_display = vd
|
||||
return False
|
||||
|
||||
def _humanize_max_seconds(self) -> float:
|
||||
if self._humanize is True:
|
||||
return 1.5
|
||||
return float(self._humanize)
|
||||
|
||||
568
src/stealthfox/prefs.py
Normal file
568
src/stealthfox/prefs.py
Normal file
|
|
@ -0,0 +1,568 @@
|
|||
"""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
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# 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, no public IP leak.
|
||||
# obfuscate_host_addresses=false: our C++ injection handles candidate
|
||||
# selection; mDNS causes mDNS-IPC to hang in sandboxed content processes.
|
||||
# disableIPv6=true keeps IPv6 out of gathering (less entropy, no IPv6 leak).
|
||||
"media.peerconnection.enabled": True,
|
||||
"media.peerconnection.ice.no_host": False,
|
||||
"media.peerconnection.ice.default_address_only": False,
|
||||
"media.peerconnection.ice.obfuscate_host_addresses": False,
|
||||
"media.peerconnection.ice.disableIPv6": 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,
|
||||
|
||||
# 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,
|
||||
|
||||
# === A/B VARIANT B: Fission disabled ===
|
||||
# Force single content-process model (e10s only, no BC outer/inner split).
|
||||
# Diagnostic for the FF150 BC-swap theory: if peet_ws/fppro/sannysoft
|
||||
# work with this off, the Juggler FF146 baseline breaks specifically on
|
||||
# cross-process navigation tracking.
|
||||
"fission.autostart": False,
|
||||
"fission.autostart.session": False,
|
||||
"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,
|
||||
}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# 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.
|
||||
|
||||
Windows: empty string. The C++ width-scale hook is a no-op and
|
||||
Firefox renders Arial/Segoe/Calibri/etc. at their native canonical
|
||||
widths. Applying the Bayesian-sampled per-font factors on a Windows
|
||||
build would *distort* real metrics and surface as a font_preferences
|
||||
width anomaly to FP Pro / reCAPTCHA.
|
||||
|
||||
Linux: prepend generic-family compensation factors so DejaVu /
|
||||
Liberation render at the widths Windows JS expects, then append the
|
||||
per-font factors that make each fabricated family detectable by
|
||||
width-diff probes.
|
||||
"""
|
||||
if not profile_metrics:
|
||||
return ""
|
||||
if sys.platform.startswith("linux"):
|
||||
return _LINUX_GENERIC_FONT_FACTORS + profile_metrics
|
||||
return "" # Windows: NEVER apply width-scale factors.
|
||||
|
||||
|
||||
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, spoofing a different GPU creates a renderer/parameters hash
|
||||
# mismatch: FP Pro hashes all 81 CN-set getParameter() values including
|
||||
# enum 7937 (RENDERER). Setting GTX 980 while ANGLE returns Intel Arc A750
|
||||
# parameters produces an OOD (hash 23d0a74b vs vanilla 66544db) that FP Pro
|
||||
# ML scores at ~0.70 (confirmed: direct SF146 vs vanilla on same machine).
|
||||
# Fix: leave renderer/vendor empty on Windows → ANGLE reports native hardware
|
||||
# (SanitizeRenderer path at ClientWebGLContext.cpp:2592-2595) → consistent.
|
||||
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:
|
||||
prefs["zoom.stealth.webgl.renderer"] = ""
|
||||
prefs["zoom.stealth.webgl.vendor"] = ""
|
||||
_renderer_lo = "intel" # test hardware is Intel Arc A750
|
||||
|
||||
# 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
|
||||
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:
|
||||
prefs["zoom.stealth.timezone"] = timezone
|
||||
prefs["juggler.timezone.override"] = timezone
|
||||
|
||||
# Cross-process seed (canvas noise + DWrite gamma share this).
|
||||
prefs["zoom.stealth.fpp.hw_seed"] = profile.seed
|
||||
prefs["zoom.stealth.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
|
||||
|
||||
# On Windows, native ANGLE extension list already matches real Windows users.
|
||||
# The baseline hard-codes a curated _WEBGL1/2_EXTENSIONS list designed for
|
||||
# Linux Mesa → clear it so Windows sessions report the native extension set
|
||||
# (hash matches real Intel Arc A750 vanilla captures).
|
||||
if not sys.platform.startswith("linux"):
|
||||
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
|
||||
4
src/stealthfox/sync_api.py
Normal file
4
src/stealthfox/sync_api.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
"""Synchronous API — re-exports Stealthfox for parity with async_api."""
|
||||
from .launcher import Stealthfox
|
||||
|
||||
__all__ = ["Stealthfox"]
|
||||
Loading…
Add table
Add a link
Reference in a new issue