diff --git a/src/invisible_playwright/_fpforge/_sampler.py b/src/invisible_playwright/_fpforge/_sampler.py index b3d7048..b977ba5 100644 --- a/src/invisible_playwright/_fpforge/_sampler.py +++ b/src/invisible_playwright/_fpforge/_sampler.py @@ -257,41 +257,31 @@ _NETWORK = Network([ # ═══════════════════════════════════════════════════════════════════════ -# FONT WHITELIST (Bayesian: core ∪ sampled_optional | gpu_class) +# FONT LIST (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). +# The browser sees ONLY these families (everything else hidden) and renders +# them from the REAL Windows font files the binary bundles in /fonts +# (MOZ_BUNDLED_FONTS). No fabricated widths: per-session metric uniqueness +# comes from the HarfBuzz per-glyph jitter (shared fpp.hw_seed), not here. # 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'. +# Optional (~40): one realistic Windows profile sampled per seed (weighted, +# deterministic) → ~3-8 optional families differ per session while staying +# centered on 'typical Windows user'. def derive_font_prefs(gpu_class: str, rng) -> Dict[str, str]: - """Build COHERENT whitelist + metrics strings for the session. + """Build the session's font family list. Profile-based (not per-font random): - - Core fonts always included (OS defaults + CSS-generic backers). - - Optional fonts come from ONE realistic Windows profile picked per seed - (weighted, deterministic). Metrics carry REAL per-family widths. + - Core families always included (OS defaults + CSS-generic backers). + - Optional families come from ONE realistic Windows profile picked per + seed (weighted, deterministic). - 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. + Returns ``{"whitelist": "arial,calibri,marlett,..."}`` — the comma-joined + family list to advertise. The binary applies it to the native system font + allow-list AT CONSTRUCTION and renders each family from the bundled real + Windows file, so glyphs and widths are genuine. To add a family, just add + an entry to font_pool.json:core/optional — no special-case code needed. """ # Profile-based (2026-06-18): pick ONE realistic Windows font profile (weighted, # deterministic per seed). Per-font random sampling is superseded — it produced @@ -319,8 +309,8 @@ def derive_font_prefs(gpu_class: str, rng) -> Dict[str, str]: else: included.extend(_FONT_OPTIONAL) # fallback (no profiles defined): all optional # Dedup by name (a profile may list a font that is also in core, e.g. after a - # standard font is promoted core→always-present) so the whitelist/metrics never - # carry a duplicate family. + # standard font is promoted core→always-present) so the list never carries a + # duplicate family. _seen: set = set() _uniq: list = [] for e in included: @@ -331,13 +321,7 @@ def derive_font_prefs(gpu_class: str, rng) -> Dict[str, str]: # Deterministic ordering: sort by name included.sort(key=lambda e: e["name"]) whitelist = ",".join(e["name"] for e in included) - # Emit the UNIVERSAL real Windows width per font (host-independent value, same everywhere). - # prefs._font_metrics_for_platform divides by the per-platform collapse base to get the C++ - # factor (measureText = base * factor = the exact Windows width on Windows/Linux/mac). - metrics = ",".join( - f'{e["name"]}|{e["width"]:.1f}' for e in included - ) - return {"whitelist": whitelist, "metrics": metrics} + return {"whitelist": whitelist} # Back-compat shim: legacy callers still import derive_font_whitelist. diff --git a/src/invisible_playwright/_fpforge/data/font_pool.json b/src/invisible_playwright/_fpforge/data/font_pool.json index bfa4157..4104fba 100644 --- a/src/invisible_playwright/_fpforge/data/font_pool.json +++ b/src/invisible_playwright/_fpforge/data/font_pool.json @@ -1,723 +1,173 @@ { "core": [ { - "name": "arial", - "width": 2256.7 + "name": "arial" }, { - "name": "arial black", - "width": 2256.7 + "name": "calibri" }, { - "name": "arial narrow", - "width": 2216.7 + "name": "cambria" }, { - "name": "bahnschrift", - "width": 2231.9 + "name": "cambria math" }, { - "name": "bahnschrift condensed", - "width": 2216.7 + "name": "candara" }, { - "name": "bahnschrift light", - "width": 2216.7 + "name": "comic sans ms" }, { - "name": "bahnschrift light condensed", - "width": 2216.7 + "name": "consolas" }, { - "name": "bahnschrift light semicondensed", - "width": 2216.7 + "name": "constantia" }, { - "name": "bahnschrift semibold", - "width": 2216.7 + "name": "corbel" }, { - "name": "bahnschrift semibold condensed", - "width": 2216.7 + "name": "courier new" }, { - "name": "bahnschrift semibold semicondensed", - "width": 2216.7 + "name": "ebrima" }, { - "name": "bahnschrift semicondensed", - "width": 2216.7 + "name": "franklin gothic" }, { - "name": "bahnschrift semilight", - "width": 2216.7 + "name": "gabriola" }, { - "name": "bahnschrift semilight condensed", - "width": 2216.7 + "name": "gadugi" }, { - "name": "bahnschrift semilight semicondensed", - "width": 2216.7 + "name": "georgia" }, { - "name": "calibri", - "width": 2139.5 + "name": "impact" }, { - "name": "calibri light", - "width": 2139.5 + "name": "javanese text" }, { - "name": "cambria", - "width": 2258.2 + "name": "lucida console" }, { - "name": "cambria math", - "width": 2258.2 + "name": "lucida sans unicode" }, { - "name": "candara", - "width": 2187.8 + "name": "ms gothic" }, { - "name": "candara light", - "width": 2187.8 + "name": "ms pgothic" }, { - "name": "cascadia code", - "width": 2362.3 + "name": "mv boli" }, { - "name": "cascadia mono", - "width": 2362.3 + "name": "malgun gothic" }, { - "name": "comic sans ms", - "width": 2361.9 + "name": "marlett" }, { - "name": "consolas", - "width": 2216.7 + "name": "microsoft himalaya" }, { - "name": "constantia", - "width": 2283.2 + "name": "microsoft jhenghei" }, { - "name": "corbel", - "width": 2129.9 + "name": "microsoft jhenghei ui" }, { - "name": "corbel light", - "width": 2129.9 + "name": "microsoft new tai lue" }, { - "name": "courier new", - "width": 2419.2 + "name": "microsoft phagspa" }, { - "name": "ebrima", - "width": 2285.2 + "name": "microsoft sans serif" }, { - "name": "franklin gothic", - "width": 2225.4 + "name": "microsoft yahei" }, { - "name": "franklin gothic medium", - "width": 2225.4 + "name": "microsoft yi baiti" }, { - "name": "gabriola", - "width": 1598.5 + "name": "mingliu-extb" }, { - "name": "gadugi", - "width": 2285.2 + "name": "mongolian baiti" }, { - "name": "georgia", - "width": 2338.7 + "name": "myanmar text" }, { - "name": "hololens mdl2 assets", - "width": 2216.7 + "name": "pmingliu-extb" }, { - "name": "impact", - "width": 2060.8 + "name": "palatino linotype" }, { - "name": "ink free", - "width": 2122.5 + "name": "segoe print" }, { - "name": "javanese text", - "width": 2311.9 + "name": "segoe script" }, { - "name": "leelawadee ui semilight", - "width": 2283.4 + "name": "segoe ui" }, { - "name": "lucida console", - "width": 2429.5 + "name": "segoe ui symbol" }, { - "name": "lucida sans unicode", - "width": 2464.3 + "name": "simsun" }, { - "name": "malgun gothic", - "width": 2351.1 + "name": "simsun-extb" }, { - "name": "malgun gothic semilight", - "width": 2351.1 + "name": "sitka small" }, { - "name": "marlett", - "width": 3740.2 + "name": "sylfaen" }, { - "name": "microsoft himalaya", - "width": 1468.4 + "name": "trebuchet ms" }, { - "name": "microsoft jhenghei", - "width": 2440.4 + "name": "verdana" }, { - "name": "microsoft jhenghei light", - "width": 2440.4 + "name": "webdings" }, { - "name": "microsoft jhenghei ui", - "width": 2440.4 + "name": "yu gothic" }, { - "name": "microsoft jhenghei ui light", - "width": 2440.4 + "name": "courier" }, { - "name": "microsoft new tai lue", - "width": 2285.2 + "name": "helvetica" }, { - "name": "microsoft phagspa", - "width": 2285.2 - }, - { - "name": "microsoft sans serif", - "width": 2256.6 - }, - { - "name": "microsoft tai le", - "width": 2285.2 - }, - { - "name": "microsoft yahei", - "width": 2487.6 - }, - { - "name": "microsoft yahei light", - "width": 2487.6 - }, - { - "name": "microsoft yahei ui", - "width": 2487.6 - }, - { - "name": "microsoft yahei ui light", - "width": 2487.6 - }, - { - "name": "microsoft yi baiti", - "width": 1830.7 - }, - { - "name": "mingliu-extb", - "width": 2016 - }, - { - "name": "mingliu_hkscs-extb", - "width": 2016 - }, - { - "name": "mongolian baiti", - "width": 2191.7 - }, - { - "name": "ms gothic", - "width": 2016 - }, - { - "name": "ms pgothic", - "width": 2031.7 - }, - { - "name": "mv boli", - "width": 2417.4 - }, - { - "name": "myanmar text", - "width": 2285.2 - }, - { - "name": "nirmala ui", - "width": 2283.4 - }, - { - "name": "nirmala ui semilight", - "width": 2283.4 - }, - { - "name": "nsimsun", - "width": 2016 - }, - { - "name": "palatino linotype", - "width": 2358.6 - }, - { - "name": "pmingliu-extb", - "width": 2065.9 - }, - { - "name": "sans serif collection", - "width": 2361.6 - }, - { - "name": "segoe mdl2 assets", - "width": 2137.2 - }, - { - "name": "segoe print", - "width": 2675.2 - }, - { - "name": "segoe script", - "width": 2830.3 - }, - { - "name": "segoe ui", - "width": 2285.2 - }, - { - "name": "segoe ui black", - "width": 2285.2 - }, - { - "name": "segoe ui emoji", - "width": 2283.4 - }, - { - "name": "segoe ui historic", - "width": 2283.4 - }, - { - "name": "segoe ui semibold", - "width": 2216.7 - }, - { - "name": "segoe ui semilight", - "width": 2216.7 - }, - { - "name": "segoe ui symbol", - "width": 2283.4 - }, - { - "name": "segoe ui variable", - "width": 2216.7 - }, - { - "name": "simsun", - "width": 2016 - }, - { - "name": "simsun-extb", - "width": 2016 - }, - { - "name": "sitka banner", - "width": 2007.8 - }, - { - "name": "sitka display", - "width": 2007.8 - }, - { - "name": "sitka heading", - "width": 2007.8 - }, - { - "name": "sitka small", - "width": 2007.8 - }, - { - "name": "sitka subheading", - "width": 2007.8 - }, - { - "name": "sitka text", - "width": 2007.8 - }, - { - "name": "sylfaen", - "width": 2279.6 - }, - { - "name": "symbol", - "width": 2223.4 - }, - { - "name": "tahoma", - "width": 2233.1 - }, - { - "name": "times new roman", - "width": 2191.7 - }, - { - "name": "trebuchet ms", - "width": 2222.4 - }, - { - "name": "verdana", - "width": 2529.1 - }, - { - "name": "webdings", - "width": 3627.5 - }, - { - "name": "wingdings", - "width": 3539.7 - }, - { - "name": "wingdings 2", - "width": 3582.1 - }, - { - "name": "wingdings 3", - "width": 2963.4 - }, - { - "name": "yu gothic", - "width": 2351.3 - }, - { - "name": "yu gothic light", - "width": 2351.3 - }, - { - "name": "yu gothic medium", - "width": 2216.7 - }, - { - "name": "yu gothic ui", - "width": 2285.2 - }, - { - "name": "yu gothic ui light", - "width": 2285.2 - }, - { - "name": "yu gothic ui semibold", - "width": 2216.7 - }, - { - "name": "yu gothic ui semilight", - "width": 2216.7 - } - ], - "optional": [ - { - "name": "aparajita", - "width": 2216.7 - }, - { - "name": "arabic typesetting", - "width": 2216.7 - }, - { - "name": "arial unicode ms", - "width": 2216.7 - }, - { - "name": "batang", - "width": 2216.7 - }, - { - "name": "century", - "width": 2397.7 - }, - { - "name": "century gothic", - "width": 2409.1 - }, - { - "name": "dengxian", - "width": 2216.7 - }, - { - "name": "dfkai-sb", - "width": 2216.7 - }, - { - "name": "dokchampa", - "width": 2216.7 - }, - { - "name": "estrangelo edessa", - "width": 2216.7 - }, - { - "name": "euphemia", - "width": 2216.7 - }, - { - "name": "fangsong", - "width": 2216.7 - }, - { - "name": "gautami", - "width": 2216.7 - }, - { - "name": "haettenschweiler", - "width": 1567.6 - }, - { - "name": "helv", - "width": 2256.6 - }, - { - "name": "iskoola pota", - "width": 2216.7 - }, - { - "name": "kaiti", - "width": 2216.7 - }, - { - "name": "kalinga", - "width": 2216.7 - }, - { - "name": "kartika", - "width": 2216.7 - }, - { - "name": "khmer ui", - "width": 2216.7 - }, - { - "name": "kokila", - "width": 2216.7 - }, - { - "name": "lao ui", - "width": 2216.7 - }, - { - "name": "latha", - "width": 2216.7 - }, - { - "name": "leelawadee", - "width": 2267.4 - }, - { - "name": "leelawadee ui", - "width": 2283.4 - }, - { - "name": "levenim mt", - "width": 2216.7 - }, - { - "name": "mangal", - "width": 2216.7 - }, - { - "name": "meiryo", - "width": 2216.7 - }, - { - "name": "meiryo ui", - "width": 2216.7 - }, - { - "name": "microsoft uighur", - "width": 1498.1 - }, - { - "name": "monotype corsiva", - "width": 1929.3 - }, - { - "name": "ms mincho", - "width": 2216.7 - }, - { - "name": "ms outlook", - "width": 2361.9 - }, - { - "name": "ms pmincho", - "width": 2216.7 - }, - { - "name": "ms reference sans serif", - "width": 2529.0 - }, - { - "name": "ms reference specialty", - "width": 2976.2 - }, - { - "name": "ms ui gothic", - "width": 2031.7 - }, - { - "name": "mt extra", - "width": 2183.8 - }, - { - "name": "nyala", - "width": 2216.7 - }, - { - "name": "plantagenet cherokee", - "width": 2216.7 - }, - { - "name": "pmingliu", - "width": 2216.7 - }, - { - "name": "pristina", - "width": 1829.1 - }, - { - "name": "raavi", - "width": 2216.7 - }, - { - "name": "segoe fluent icons", - "width": 2142.7 - }, - { - "name": "segoe ui light", - "width": 2216.7 - }, - { - "name": "shonar bangla", - "width": 2216.7 - }, - { - "name": "shruti", - "width": 2216.7 - }, - { - "name": "simhei", - "width": 2216.7 - }, - { - "name": "simkai", - "width": 2216.7 - }, - { - "name": "small fonts", - "width": 2256.7 - }, - { - "name": "traditional arabic", - "width": 2216.7 - }, - { - "name": "tunga", - "width": 2216.7 - }, - { - "name": "urdu typesetting", - "width": 2216.7 - }, - { - "name": "utsaah", - "width": 2216.7 - }, - { - "name": "vani", - "width": 2216.7 - }, - { - "name": "vijaya", - "width": 2216.7 - }, - { - "name": "vrinda", - "width": 2216.7 - }, - { - "name": "yu mincho", - "width": 2216.7 + "name": "segoe ui light" } ], + "optional": [], "profiles": [ { - "name": "win_office", - "weight": 40, - "optional": [ - "century", - "century gothic", - "helv", - "haettenschweiler", - "leelawadee", - "ms outlook", - "ms reference specialty", - "ms ui gothic", - "mt extra", - "microsoft uighur", - "monotype corsiva", - "pristina", - "segoe ui light", - "small fonts" - ] - }, - { - "name": "win_base", + "name": "win_clean", "weight": 35, - "optional": [ - "century", - "century gothic", - "helv", - "haettenschweiler", - "leelawadee", - "ms ui gothic", - "microsoft uighur", - "monotype corsiva", - "pristina", - "segoe ui light", - "small fonts" - ] + "optional": [] }, { - "name": "win_lean", - "weight": 25, - "optional": [ - "century", - "century gothic", - "helv", - "haettenschweiler", - "leelawadee", - "ms ui gothic", - "segoe ui light", - "small fonts" - ] + "name": "win_office", + "weight": 65, + "optional": [] } ] -} \ No newline at end of file +} diff --git a/src/invisible_playwright/_fpforge/data/webgl_gpu_pool.json b/src/invisible_playwright/_fpforge/data/webgl_gpu_pool.json index 34f757b..76f0fe5 100644 --- a/src/invisible_playwright/_fpforge/data/webgl_gpu_pool.json +++ b/src/invisible_playwright/_fpforge/data/webgl_gpu_pool.json @@ -3,7 +3,7 @@ { "vendor": "Google Inc. (NVIDIA)", "renderer_out": "ANGLE (NVIDIA, NVIDIA GeForce GTX 980 Direct3D11 vs_5_0 ps_5_0), or similar", - "prob": 0.462396, + "prob": 0.470921, "prefs": { "zoom.stealth.webgl.renderer": "ANGLE (NVIDIA, NVIDIA GeForce GTX 980 Direct3D11 vs_5_0 ps_5_0, D3D11)", "zoom.stealth.webgl.vendor": "Google Inc. (NVIDIA)", @@ -19,7 +19,7 @@ { "vendor": "Google Inc. (Intel)", "renderer_out": "ANGLE (Intel, Intel(R) HD Graphics Direct3D11 vs_5_0 ps_5_0), or similar", - "prob": 0.192201, + "prob": 0.195745, "prefs": { "zoom.stealth.webgl.renderer": "ANGLE (Intel, Intel(R) HD Graphics Direct3D11 vs_5_0 ps_5_0, D3D11)", "zoom.stealth.webgl.vendor": "Google Inc. (Intel)", @@ -35,7 +35,7 @@ { "vendor": "Google Inc. (Intel)", "renderer_out": "ANGLE (Intel, Intel(R) HD Graphics 400 Direct3D11 vs_5_0 ps_5_0), or similar", - "prob": 0.157382, + "prob": 0.160284, "prefs": { "zoom.stealth.webgl.renderer": "ANGLE (Intel, Intel(R) HD Graphics 400 Direct3D11 vs_5_0 ps_5_0, D3D11)", "zoom.stealth.webgl.vendor": "Google Inc. (Intel)", @@ -51,7 +51,7 @@ { "vendor": "Google Inc. (AMD)", "renderer_out": "ANGLE (AMD, Radeon HD 3200 Graphics Direct3D11 vs_5_0 ps_5_0), or similar", - "prob": 0.084958, + "prob": 0.086524, "prefs": { "zoom.stealth.webgl.renderer": "ANGLE (AMD, Radeon HD 3200 Graphics Direct3D11 vs_5_0 ps_5_0, D3D11)", "zoom.stealth.webgl.vendor": "Google Inc. (AMD)", @@ -67,7 +67,7 @@ { "vendor": "Google Inc. (AMD)", "renderer_out": "ANGLE (AMD, Radeon R9 200 Series Direct3D11 vs_5_0 ps_5_0), or similar", - "prob": 0.052925, + "prob": 0.053901, "prefs": { "zoom.stealth.webgl.renderer": "ANGLE (AMD, Radeon R9 200 Series Direct3D11 vs_5_0 ps_5_0, D3D11)", "zoom.stealth.webgl.vendor": "Google Inc. (AMD)", @@ -80,26 +80,10 @@ "zoom.stealth.webgl2.enabled": true } }, - { - "vendor": "Google Inc. (NVIDIA)", - "renderer_out": "ANGLE (NVIDIA, NVIDIA GeForce GTX 480 Direct3D11 vs_5_0 ps_5_0), or similar", - "prob": 0.013928, - "prefs": { - "zoom.stealth.webgl.renderer": "ANGLE (NVIDIA, NVIDIA GeForce GTX 480 Direct3D11 vs_5_0 ps_5_0, D3D11)", - "zoom.stealth.webgl.vendor": "Google Inc. (NVIDIA)", - "zoom.stealth.webgl.extensions": "ANGLE_instanced_arrays,EXT_blend_minmax,EXT_color_buffer_half_float,EXT_float_blend,EXT_frag_depth,EXT_shader_texture_lod,EXT_sRGB,EXT_texture_compression_bptc,EXT_texture_compression_rgtc,EXT_texture_filter_anisotropic,OES_element_index_uint,OES_fbo_render_mipmap,OES_standard_derivatives,OES_texture_float,OES_texture_float_linear,OES_texture_half_float,OES_texture_half_float_linear,OES_vertex_array_object,WEBGL_color_buffer_float,WEBGL_compressed_texture_s3tc,WEBGL_compressed_texture_s3tc_srgb,WEBGL_debug_renderer_info,WEBGL_debug_shaders,WEBGL_depth_texture,WEBGL_draw_buffers,WEBGL_lose_context,WEBGL_provoking_vertex", - "zoom.stealth.webgl2.extensions": "EXT_color_buffer_float,EXT_float_blend,EXT_texture_compression_bptc,EXT_texture_compression_rgtc,EXT_texture_filter_anisotropic,OES_draw_buffers_indexed,OES_texture_float_linear,OVR_multiview2,WEBGL_compressed_texture_s3tc,WEBGL_compressed_texture_s3tc_srgb,WEBGL_debug_renderer_info,WEBGL_debug_shaders,WEBGL_lose_context,WEBGL_provoking_vertex", - "zoom.stealth.webgl.int_params": "2849|1,2885|1029,2886|2305,2931|1,2932|513,2961|0,2962|519,2963|2147483647,2964|7680,2965|7680,2966|7680,2967|0,2968|2147483647,3074|1029,3314|0,3315|0,3316|0,3317|4,3330|0,3331|0,3332|0,3333|4,3379|16384,3408|4,3410|8,3411|8,3412|8,3413|8,3414|24,3415|0,10752|0,32777|32774,32824|0,32877|0,32878|0,32883|2048,32936|1,32937|4,32938|1,32968|0,32969|1,32970|0,32971|1,33000|2147483647,33001|2147483647,33170|4352,34016|33984,34024|16384,34045|2,34076|16384,34816|519,34817|7680,34818|7680,34819|7680,34852|8,34853|1029,34854|0,34855|0,34856|0,34857|0,34858|0,34859|0,34860|0,34877|32774,34921|16,34930|16,35071|2048,35076|-8,35077|7,35371|12,35373|12,35374|24,35375|24,35376|65536,35377|212988,35379|200704,35380|256,35657|4096,35658|16380,35659|120,35660|16,35661|32,35723|4352,35738|5121,35739|6408,35968|4,35978|120,35979|4,36003|0,36004|2147483647,36005|2147483647,36063|8,36183|8,36203|4294967294,36347|4095,36348|30,36349|1024,37137|0,37154|120,37157|120,37443|37444,37447|1000000000", - "zoom.stealth.webgl.int2_params": "2928|0:1,3386|32767:32767,33901|1:1024,33902|1:1", - "zoom.stealth.webgl.float_params": "", - "zoom.stealth.webgl.shader_precisions": "35633*36336|127:127:23,35633*36337|127:127:23,35633*36338|127:127:23,35633*36339|31:30:0,35633*36340|31:30:0,35633*36341|31:30:0,35632*36336|127:127:23,35632*36337|127:127:23,35632*36338|127:127:23,35632*36339|31:30:0,35632*36340|31:30:0,35632*36341|31:30:0", - "zoom.stealth.webgl2.enabled": true - } - }, { "vendor": "Google Inc. (Microsoft)", "renderer_out": "ANGLE (Microsoft, Microsoft Basic Render Driver Direct3D11 vs_5_0 ps_5_0), or similar", - "prob": 0.012535, + "prob": 0.012766, "prefs": { "zoom.stealth.webgl.renderer": "ANGLE (Microsoft, Microsoft Basic Render Driver Direct3D11 vs_5_0 ps_5_0, D3D11)", "zoom.stealth.webgl.vendor": "Google Inc. (Microsoft)", @@ -115,9 +99,9 @@ { "vendor": "Google Inc. (Intel)", "renderer_out": "ANGLE (Intel, Intel(R) HD Graphics Direct3D11 vs_5_0 ps_5_0)", - "prob": 0.006964, + "prob": 0.007092, "prefs": { - "zoom.stealth.webgl.renderer": "ANGLE (Intel, Intel(R) HD Graphics Direct3D11 vs_5_0 ps_5_0)", + "zoom.stealth.webgl.renderer": "ANGLE (Intel, Intel(R) HD Graphics Direct3D11 vs_5_0 ps_5_0, D3D11)", "zoom.stealth.webgl.vendor": "Google Inc. (Intel)", "zoom.stealth.webgl.extensions": "ANGLE_instanced_arrays,EXT_blend_minmax,EXT_color_buffer_half_float,EXT_float_blend,EXT_frag_depth,EXT_shader_texture_lod,EXT_sRGB,EXT_texture_compression_bptc,EXT_texture_compression_rgtc,EXT_texture_filter_anisotropic,OES_element_index_uint,OES_fbo_render_mipmap,OES_standard_derivatives,OES_texture_float,OES_texture_float_linear,OES_texture_half_float,OES_texture_half_float_linear,OES_vertex_array_object,WEBGL_color_buffer_float,WEBGL_compressed_texture_s3tc,WEBGL_compressed_texture_s3tc_srgb,WEBGL_debug_renderer_info,WEBGL_debug_shaders,WEBGL_depth_texture,WEBGL_draw_buffers,WEBGL_lose_context,WEBGL_provoking_vertex", "zoom.stealth.webgl2.extensions": "EXT_color_buffer_float,EXT_float_blend,EXT_texture_compression_bptc,EXT_texture_compression_rgtc,EXT_texture_filter_anisotropic,OES_draw_buffers_indexed,OES_texture_float_linear,OVR_multiview2,WEBGL_compressed_texture_s3tc,WEBGL_compressed_texture_s3tc_srgb,WEBGL_debug_renderer_info,WEBGL_debug_shaders,WEBGL_lose_context,WEBGL_provoking_vertex", @@ -131,7 +115,7 @@ { "vendor": "Google Inc. (Intel)", "renderer_out": "ANGLE (Intel, Intel(R) HD Graphics Direct3D11 vs_4_1 ps_4_1), or similar", - "prob": 0.004178, + "prob": 0.004255, "prefs": { "zoom.stealth.webgl.renderer": "ANGLE (Intel, Intel(R) HD Graphics Direct3D11 vs_4_1 ps_4_1, D3D11)", "zoom.stealth.webgl.vendor": "Google Inc. (Intel)", @@ -144,28 +128,12 @@ "zoom.stealth.webgl2.enabled": true } }, - { - "vendor": "Google Inc. (Google)", - "renderer_out": "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)", - "prob": 0.002786, - "prefs": { - "zoom.stealth.webgl.renderer": "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)", - "zoom.stealth.webgl.vendor": "Google Inc. (Google)", - "zoom.stealth.webgl.extensions": "ANGLE_instanced_arrays,EXT_blend_minmax,EXT_color_buffer_half_float,EXT_float_blend,EXT_frag_depth,EXT_shader_texture_lod,EXT_texture_compression_bptc,EXT_texture_compression_rgtc,EXT_texture_filter_anisotropic,EXT_sRGB,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_astc,WEBGL_compressed_texture_etc,WEBGL_compressed_texture_etc1,WEBGL_compressed_texture_s3tc,WEBGL_compressed_texture_s3tc_srgb,WEBGL_debug_renderer_info,WEBGL_depth_texture,WEBGL_draw_buffers,WEBGL_lose_context,WEBGL_multi_draw", - "zoom.stealth.webgl2.extensions": "EXT_color_buffer_float,EXT_color_buffer_half_float,EXT_float_blend,EXT_texture_compression_bptc,EXT_texture_compression_rgtc,EXT_texture_filter_anisotropic,EXT_texture_norm16,OES_draw_buffers_indexed,OES_texture_float_linear,OVR_multiview2,WEBGL_clip_cull_distance,WEBGL_compressed_texture_astc,WEBGL_compressed_texture_etc,WEBGL_compressed_texture_etc1,WEBGL_compressed_texture_s3tc,WEBGL_compressed_texture_s3tc_srgb,WEBGL_debug_renderer_info,WEBGL_lose_context,WEBGL_multi_draw", - "zoom.stealth.webgl.int_params": "2849|1,2885|1029,2886|2305,2931|1,2932|513,2961|0,2962|519,2963|2147483647,2964|7680,2965|7680,2966|7680,2967|0,2968|2147483647,3074|1029,3314|0,3315|0,3316|0,3317|4,3330|0,3331|0,3332|0,3333|4,3379|8192,3408|4,3410|8,3411|8,3412|8,3413|8,3414|24,3415|0,10752|0,32777|32774,32824|0,32877|0,32878|0,32883|2048,32936|1,32937|4,32938|1,32968|0,32969|1,32970|0,32971|1,33000|2147483647,33001|2147483647,33170|4352,34016|33984,34024|8192,34045|15,34076|16384,34816|519,34817|7680,34818|7680,34819|7680,34852|8,34853|1029,34854|1029,34855|1029,34856|1029,34857|1029,34858|1029,34859|1029,34860|1029,34877|32774,34921|16,34930|32,35071|2048,35076|-8,35077|7,35371|14,35373|14,35374|60,35375|72,35376|65536,35377|245760,35379|245760,35380|256,35657|16384,35658|16384,35659|124,35660|32,35661|64,35723|4352,35738|5121,35739|6408,35968|4,35978|128,35979|4,36003|0,36004|2147483647,36005|2147483647,36063|8,36183|4,36203|4294967294,36345|1,36347|4096,36348|31,36349|4096,37137|0,37154|128,37157|128,37443|37444,37447|0", - "zoom.stealth.webgl.int2_params": "2928|0:1,3386|8192:8192,33901|1:1023,33902|1:1", - "zoom.stealth.webgl.float_params": "", - "zoom.stealth.webgl.shader_precisions": "35633*36336|15:15:10,35633*36337|15:15:10,35633*36338|127:127:23,35633*36339|15:14:0,35633*36340|15:14:0,35633*36341|31:30:0,35632*36336|15:15:10,35632*36337|15:15:10,35632*36338|127:127:23,35632*36339|15:14:0,35632*36340|15:14:0,35632*36341|31:30:0", - "zoom.stealth.webgl2.enabled": true - } - }, { "vendor": "Google Inc. (AMD)", "renderer_out": "ANGLE (AMD, Radeon R9 200 Series Direct3D11 vs_5_0 ps_5_0)", - "prob": 0.001393, + "prob": 0.001419, "prefs": { - "zoom.stealth.webgl.renderer": "ANGLE (AMD, Radeon R9 200 Series Direct3D11 vs_5_0 ps_5_0)", + "zoom.stealth.webgl.renderer": "ANGLE (AMD, Radeon R9 200 Series Direct3D11 vs_5_0 ps_5_0, D3D11)", "zoom.stealth.webgl.vendor": "Google Inc. (AMD)", "zoom.stealth.webgl.extensions": "ANGLE_instanced_arrays,EXT_blend_minmax,EXT_color_buffer_half_float,EXT_float_blend,EXT_frag_depth,EXT_shader_texture_lod,EXT_sRGB,EXT_texture_compression_bptc,EXT_texture_compression_rgtc,EXT_texture_filter_anisotropic,OES_element_index_uint,OES_fbo_render_mipmap,OES_standard_derivatives,OES_texture_float,OES_texture_float_linear,OES_texture_half_float,OES_texture_half_float_linear,OES_vertex_array_object,WEBGL_color_buffer_float,WEBGL_compressed_texture_s3tc,WEBGL_compressed_texture_s3tc_srgb,WEBGL_debug_renderer_info,WEBGL_debug_shaders,WEBGL_depth_texture,WEBGL_draw_buffers,WEBGL_lose_context,WEBGL_provoking_vertex", "zoom.stealth.webgl2.extensions": "EXT_color_buffer_float,EXT_float_blend,EXT_texture_compression_bptc,EXT_texture_compression_rgtc,EXT_texture_filter_anisotropic,OES_draw_buffers_indexed,OES_texture_float_linear,OVR_multiview2,WEBGL_compressed_texture_s3tc,WEBGL_compressed_texture_s3tc_srgb,WEBGL_debug_renderer_info,WEBGL_debug_shaders,WEBGL_lose_context,WEBGL_provoking_vertex", @@ -179,9 +147,9 @@ { "vendor": "Google Inc. (Intel)", "renderer_out": "ANGLE (Intel, Intel(R) HD Graphics Direct3D11 vs_4_0 ps_4_0)", - "prob": 0.001393, + "prob": 0.001419, "prefs": { - "zoom.stealth.webgl.renderer": "ANGLE (Intel, Intel(R) HD Graphics Direct3D11 vs_4_0 ps_4_0)", + "zoom.stealth.webgl.renderer": "ANGLE (Intel, Intel(R) HD Graphics Direct3D11 vs_4_0 ps_4_0, D3D11)", "zoom.stealth.webgl.vendor": "Google Inc. (Intel)", "zoom.stealth.webgl.extensions": "ANGLE_instanced_arrays,EXT_blend_minmax,EXT_color_buffer_half_float,EXT_float_blend,EXT_frag_depth,EXT_shader_texture_lod,EXT_sRGB,EXT_texture_compression_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", "zoom.stealth.webgl2.extensions": "", @@ -195,9 +163,9 @@ { "vendor": "Google Inc. (NVIDIA)", "renderer_out": "ANGLE (NVIDIA, NVIDIA GeForce 8800 GTX Direct3D11 vs_4_0 ps_4_0)", - "prob": 0.001393, + "prob": 0.001419, "prefs": { - "zoom.stealth.webgl.renderer": "ANGLE (NVIDIA, NVIDIA GeForce 8800 GTX Direct3D11 vs_4_0 ps_4_0)", + "zoom.stealth.webgl.renderer": "ANGLE (NVIDIA, NVIDIA GeForce 8800 GTX Direct3D11 vs_4_0 ps_4_0, D3D11)", "zoom.stealth.webgl.vendor": "Google Inc. (NVIDIA)", "zoom.stealth.webgl.extensions": "ANGLE_instanced_arrays,EXT_blend_minmax,EXT_color_buffer_half_float,EXT_float_blend,EXT_frag_depth,EXT_shader_texture_lod,EXT_sRGB,EXT_texture_compression_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", "zoom.stealth.webgl2.extensions": "", @@ -211,9 +179,9 @@ { "vendor": "Google Inc. (Intel)", "renderer_out": "ANGLE (Intel, Intel 945GM Direct3D11 vs_4_0 ps_4_0)", - "prob": 0.001393, + "prob": 0.001419, "prefs": { - "zoom.stealth.webgl.renderer": "ANGLE (Intel, Intel 945GM Direct3D11 vs_4_0 ps_4_0)", + "zoom.stealth.webgl.renderer": "ANGLE (Intel, Intel 945GM Direct3D11 vs_4_0 ps_4_0, D3D11)", "zoom.stealth.webgl.vendor": "Google Inc. (Intel)", "zoom.stealth.webgl.extensions": "ANGLE_instanced_arrays,EXT_blend_minmax,EXT_color_buffer_half_float,EXT_float_blend,EXT_frag_depth,EXT_shader_texture_lod,EXT_sRGB,EXT_texture_compression_rgtc,EXT_texture_filter_anisotropic,OES_element_index_uint,OES_fbo_render_mipmap,OES_standard_derivatives,OES_texture_float,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", "zoom.stealth.webgl2.extensions": "", @@ -227,9 +195,9 @@ { "vendor": "Google Inc. (Intel)", "renderer_out": "ANGLE (Intel, Intel(R) HD Graphics Direct3D11 vs_4_1 ps_4_1)", - "prob": 0.001393, + "prob": 0.001419, "prefs": { - "zoom.stealth.webgl.renderer": "ANGLE (Intel, Intel(R) HD Graphics Direct3D11 vs_4_1 ps_4_1)", + "zoom.stealth.webgl.renderer": "ANGLE (Intel, Intel(R) HD Graphics Direct3D11 vs_4_1 ps_4_1, D3D11)", "zoom.stealth.webgl.vendor": "Google Inc. (Intel)", "zoom.stealth.webgl.extensions": "ANGLE_instanced_arrays,EXT_blend_minmax,EXT_color_buffer_half_float,EXT_float_blend,EXT_frag_depth,EXT_shader_texture_lod,EXT_sRGB,EXT_texture_compression_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", "zoom.stealth.webgl2.extensions": "EXT_color_buffer_float,EXT_float_blend,EXT_texture_compression_rgtc,EXT_texture_filter_anisotropic,OES_draw_buffers_indexed,OES_texture_float_linear,WEBGL_compressed_texture_s3tc,WEBGL_compressed_texture_s3tc_srgb,WEBGL_debug_renderer_info,WEBGL_debug_shaders,WEBGL_lose_context,WEBGL_provoking_vertex", @@ -243,25 +211,9 @@ { "vendor": "Google Inc. (NVIDIA)", "renderer_out": "ANGLE (NVIDIA, NVIDIA GeForce GTX 980 Direct3D11 vs_5_0 ps_5_0)", - "prob": 0.001393, + "prob": 0.001419, "prefs": { - "zoom.stealth.webgl.renderer": "ANGLE (NVIDIA, NVIDIA GeForce GTX 980 Direct3D11 vs_5_0 ps_5_0)", - "zoom.stealth.webgl.vendor": "Google Inc. (NVIDIA)", - "zoom.stealth.webgl.extensions": "ANGLE_instanced_arrays,EXT_blend_minmax,EXT_color_buffer_half_float,EXT_float_blend,EXT_frag_depth,EXT_shader_texture_lod,EXT_sRGB,EXT_texture_compression_bptc,EXT_texture_compression_rgtc,EXT_texture_filter_anisotropic,OES_element_index_uint,OES_fbo_render_mipmap,OES_standard_derivatives,OES_texture_float,OES_texture_float_linear,OES_texture_half_float,OES_texture_half_float_linear,OES_vertex_array_object,WEBGL_color_buffer_float,WEBGL_compressed_texture_s3tc,WEBGL_compressed_texture_s3tc_srgb,WEBGL_debug_renderer_info,WEBGL_debug_shaders,WEBGL_depth_texture,WEBGL_draw_buffers,WEBGL_lose_context,WEBGL_provoking_vertex", - "zoom.stealth.webgl2.extensions": "EXT_color_buffer_float,EXT_float_blend,EXT_texture_compression_bptc,EXT_texture_compression_rgtc,EXT_texture_filter_anisotropic,OES_draw_buffers_indexed,OES_texture_float_linear,OVR_multiview2,WEBGL_compressed_texture_s3tc,WEBGL_compressed_texture_s3tc_srgb,WEBGL_debug_renderer_info,WEBGL_debug_shaders,WEBGL_lose_context,WEBGL_provoking_vertex", - "zoom.stealth.webgl.int_params": "2849|1,2885|1029,2886|2305,2931|1,2932|513,2961|0,2962|519,2963|2147483647,2964|7680,2965|7680,2966|7680,2967|0,2968|2147483647,3074|1029,3314|0,3315|0,3316|0,3317|4,3330|0,3331|0,3332|0,3333|4,3379|16384,3408|4,3410|8,3411|8,3412|8,3413|8,3414|24,3415|0,10752|0,32777|32774,32824|0,32877|0,32878|0,32883|2048,32936|1,32937|4,32938|1,32968|0,32969|1,32970|0,32971|1,33000|2147483647,33001|2147483647,33170|4352,34016|33984,34024|16384,34045|2,34076|16384,34816|519,34817|7680,34818|7680,34819|7680,34852|8,34853|1029,34854|0,34855|0,34856|0,34857|0,34858|0,34859|0,34860|0,34877|32774,34921|16,34930|16,35071|2048,35076|-8,35077|7,35371|12,35373|12,35374|24,35375|24,35376|65536,35377|212988,35379|200704,35380|256,35657|4096,35658|16380,35659|120,35660|16,35661|32,35723|4352,35738|5121,35739|6408,35968|4,35978|120,35979|4,36003|0,36004|2147483647,36005|2147483647,36063|8,36183|8,36203|4294967294,36347|4095,36348|30,36349|1024,37137|0,37154|120,37157|120,37443|37444,37447|1000000000", - "zoom.stealth.webgl.int2_params": "2928|0:1,3386|32767:32767,33901|1:1024,33902|1:1", - "zoom.stealth.webgl.float_params": "", - "zoom.stealth.webgl.shader_precisions": "35633*36336|127:127:23,35633*36337|127:127:23,35633*36338|127:127:23,35633*36339|31:30:0,35633*36340|31:30:0,35633*36341|31:30:0,35632*36336|127:127:23,35632*36337|127:127:23,35632*36338|127:127:23,35632*36339|31:30:0,35632*36340|31:30:0,35632*36341|31:30:0", - "zoom.stealth.webgl2.enabled": true - } - }, - { - "vendor": "Google Inc. (NVIDIA)", - "renderer_out": "ANGLE (NVIDIA, NVIDIA GeForce GTX 480 Direct3D11 vs_5_0 ps_5_0)", - "prob": 0.001393, - "prefs": { - "zoom.stealth.webgl.renderer": "ANGLE (NVIDIA, NVIDIA GeForce GTX 480 Direct3D11 vs_5_0 ps_5_0)", + "zoom.stealth.webgl.renderer": "ANGLE (NVIDIA, NVIDIA GeForce GTX 980 Direct3D11 vs_5_0 ps_5_0, D3D11)", "zoom.stealth.webgl.vendor": "Google Inc. (NVIDIA)", "zoom.stealth.webgl.extensions": "ANGLE_instanced_arrays,EXT_blend_minmax,EXT_color_buffer_half_float,EXT_float_blend,EXT_frag_depth,EXT_shader_texture_lod,EXT_sRGB,EXT_texture_compression_bptc,EXT_texture_compression_rgtc,EXT_texture_filter_anisotropic,OES_element_index_uint,OES_fbo_render_mipmap,OES_standard_derivatives,OES_texture_float,OES_texture_float_linear,OES_texture_half_float,OES_texture_half_float_linear,OES_vertex_array_object,WEBGL_color_buffer_float,WEBGL_compressed_texture_s3tc,WEBGL_compressed_texture_s3tc_srgb,WEBGL_debug_renderer_info,WEBGL_debug_shaders,WEBGL_depth_texture,WEBGL_draw_buffers,WEBGL_lose_context,WEBGL_provoking_vertex", "zoom.stealth.webgl2.extensions": "EXT_color_buffer_float,EXT_float_blend,EXT_texture_compression_bptc,EXT_texture_compression_rgtc,EXT_texture_filter_anisotropic,OES_draw_buffers_indexed,OES_texture_float_linear,OVR_multiview2,WEBGL_compressed_texture_s3tc,WEBGL_compressed_texture_s3tc_srgb,WEBGL_debug_renderer_info,WEBGL_debug_shaders,WEBGL_lose_context,WEBGL_provoking_vertex", diff --git a/src/invisible_playwright/_geo.py b/src/invisible_playwright/_geo.py index 7423b2b..5c552f2 100644 --- a/src/invisible_playwright/_geo.py +++ b/src/invisible_playwright/_geo.py @@ -136,6 +136,56 @@ def ip_to_timezone(ip: str, mmdb_path: Any) -> str: return tz +# ISO 3166 country code -> the primary BCP-47 locale a real Windows machine in that +# country most commonly runs. Multi-language countries use the majority language; the +# user can always force a specific locale instead of "auto". Unknown -> en-US. +_COUNTRY_LOCALE = { + "US": "en-US", "GB": "en-GB", "CA": "en-CA", "AU": "en-AU", "NZ": "en-NZ", "IE": "en-IE", + "ZA": "en-ZA", "IN": "en-IN", "SG": "en-SG", "PH": "en-PH", + "FR": "fr-FR", "BE": "fr-BE", "LU": "fr-LU", + "DE": "de-DE", "AT": "de-AT", "CH": "de-CH", + "IT": "it-IT", "ES": "es-ES", "PT": "pt-PT", "NL": "nl-NL", + "SE": "sv-SE", "NO": "nb-NO", "DK": "da-DK", "FI": "fi-FI", "IS": "is-IS", + "PL": "pl-PL", "CZ": "cs-CZ", "SK": "sk-SK", "HU": "hu-HU", "RO": "ro-RO", + "GR": "el-GR", "BG": "bg-BG", "HR": "hr-HR", "RS": "sr-RS", "SI": "sl-SI", + "RU": "ru-RU", "UA": "uk-UA", "TR": "tr-TR", "IL": "he-IL", + "BR": "pt-BR", "MX": "es-MX", "AR": "es-AR", "CL": "es-CL", "CO": "es-CO", "PE": "es-PE", + "JP": "ja-JP", "KR": "ko-KR", "CN": "zh-CN", "TW": "zh-TW", "HK": "zh-HK", + "ID": "id-ID", "TH": "th-TH", "VN": "vi-VN", "MY": "ms-MY", + "SA": "ar-SA", "AE": "ar-AE", "EG": "ar-EG", +} + + +def ip_to_locale(ip: str, mmdb_path: Any) -> str: + """Map ``ip`` -> a BCP-47 locale via the MaxMind ``country.iso_code`` field, so the + browser language stays consistent with the proxy egress country. Falls back to + ``en-US`` for IPs absent from the DB or countries we don't map.""" + import maxminddb + + with maxminddb.open_database(str(mmdb_path)) as reader: + record = reader.get(ip) + cc = "" + if isinstance(record, dict): + cc = ((record.get("country") or {}).get("iso_code") or "") + return _COUNTRY_LOCALE.get(cc.upper(), "en-US") + + +def resolve_session_locale(egress_ip: Optional[str], proxy: Optional[Dict[str, str]]) -> str: + """Resolve ``locale="auto"`` to a BCP-47 locale from the egress country. Behind a proxy + it reuses the already-discovered ``egress_ip`` (no extra round-trip); without a proxy it + discovers the host's public IP. On any failure it returns ``en-US`` (never breaks launch + — locale is cosmetic, unlike timezone which traps a foreign-proxy mismatch).""" + from .download import ensure_geoip_mmdb + + try: + ip = egress_ip if _proxy_is_set(proxy) else discover_egress_ip(None) + if ip is None: + return "en-US" + return ip_to_locale(ip, ensure_geoip_mmdb()) + except Exception: # noqa: BLE001 + return "en-US" + + class SessionGeo(NamedTuple): """Geo facts resolved once per session from a single egress round-trip. diff --git a/src/invisible_playwright/_webgl_personas.py b/src/invisible_playwright/_webgl_personas.py index 3381b6c..18ab1b1 100644 --- a/src/invisible_playwright/_webgl_personas.py +++ b/src/invisible_playwright/_webgl_personas.py @@ -185,6 +185,12 @@ def forced_gpu_class(seed: int) -> Optional[str]: # only on the retired amd/arc mix) → dropped. NVIDIA is the worst case, so these are clean on # amd/intel too. hw_seed = the canvas/WebGL gamma render hash (the dominant consistency-score # driver); host-calibrated. +# 2026-06-21: with WebGL Option B (zoom.stealth.webgl.substitute_pixels, ON in prefs.py) the WebGL +# render hash is hash(seed,idx) = HOST-INDEPENDENT, so this list NO LONGER needs per-host calibration +# — it only supplies per-session diversity. A 2026-06-21 attempt to re-calibrate it per-host FAILED +# cross-OS: hw_seed clean on Windows went dirty on the Linux GL backend (b008 0.034->0.839; Win-dirty +# {7,11,20,27} = Linux-clean and vice-versa; + identity×hw_seed interaction on Linux). That proved +# calibration can't work cross-host → substitution replaces it. Kept the original diverse 9-set. CLEAN_RENDER_SEEDS = [0, 5, 6, 9, 11, 16, 19, 20, 28] diff --git a/src/invisible_playwright/async_api.py b/src/invisible_playwright/async_api.py index 8555b3f..2c04938 100644 --- a/src/invisible_playwright/async_api.py +++ b/src/invisible_playwright/async_api.py @@ -48,7 +48,7 @@ class InvisiblePlaywright: proxy: Optional[Dict[str, str]] = None, extra_args: Optional[list[str]] = None, humanize: Union[bool, float] = True, - locale: str = "en-US", + locale: str = "auto", timezone: str = "", extra_prefs: Optional[Dict[str, Any]] = None, binary_path: Optional[str] = None, @@ -90,6 +90,14 @@ class InvisiblePlaywright: ) self._timezone = _geo.timezone self._webrtc_egress_ip = _geo.egress_ip + # Geo-aware locale: "auto" derives the language from the egress country (reusing + # the egress IP just discovered), like timezone="auto". Keeps the browser language + # consistent with the proxy's country instead of a fixed en-US. + if (self._locale or "").strip().lower() == "auto": + from ._geo import resolve_session_locale + self._locale = await asyncio.to_thread( + resolve_session_locale, _geo.egress_ip, self._proxy + ) executable = self._binary_path or ensure_binary() prefs = translate_profile_to_prefs( self._profile, @@ -113,7 +121,7 @@ class InvisiblePlaywright: prefs["stealthfox.humanize.maxTime"] = str(cap) playwright_proxy = _configure_proxy_shared(self._proxy, prefs) pw_headless = self._resolve_headless() - env = self._build_env() + env = self._build_env(prefs) try: self._pw = await async_playwright().start() if self._profile_dir is not None: @@ -214,11 +222,21 @@ class InvisiblePlaywright: pass self._virtual_display = None - def _build_env(self) -> Dict[str, str]: + def _build_env(self, prefs: Dict[str, Any]) -> Dict[str, str]: import os as _os env = _os.environ.copy() if self._timezone: env["TZ"] = _tz_env(self._timezone) + # Font allow-list + system-ui via env (read at the gfxPlatformFontList + # constructor, process start) — Playwright delivers firefox_user_prefs + # over the juggler protocol after start, too late for the font list ctor, + # so without this host fonts leak on Linux/macOS. See sync launcher. + fontlist = prefs.get("zoom.stealth.font.fontlist") + if fontlist: + env["STEALTHFOX_FONTLIST"] = fontlist + system_ui = prefs.get("zoom.stealth.font.system_ui") + if system_ui: + env["STEALTHFOX_SYSTEMUI"] = system_ui # WebRTC srflx override: feed nICEr's nr_stealth_bridge the proxy egress # IP (caller's explicit env var wins, else the IP auto-discovered in # __aenter__) and drop IPv6 from gathering behind a proxy. diff --git a/src/invisible_playwright/launcher.py b/src/invisible_playwright/launcher.py index 510fa0f..559ba2f 100644 --- a/src/invisible_playwright/launcher.py +++ b/src/invisible_playwright/launcher.py @@ -110,7 +110,7 @@ class InvisiblePlaywright: proxy: Optional[Dict[str, str]] = None, extra_args: Optional[list[str]] = None, humanize: Union[bool, float] = True, - locale: str = "en-US", + locale: str = "auto", timezone: str = "", extra_prefs: Optional[Dict[str, Any]] = None, binary_path: Optional[str] = None, @@ -135,8 +135,15 @@ class InvisiblePlaywright: into a Bezier trajectory with ~10 ms between waypoints. Default ``True`` (~1.5 s max motion). ``False`` disables; a float caps the motion in seconds. - locale: BCP-47 tag (e.g. ``"en-US"``). Drives the - ``Accept-Language`` header and ``navigator.language``. + locale: BCP-47 tag (e.g. ``"en-US"``) or ``"auto"`` (default). + ``"auto"`` derives the locale from the egress country — the proxy + egress IP, or the host's public IP without a proxy — exactly like + ``timezone="auto"``, keeping the browser language consistent with the + exit country (a French proxy → ``fr-FR``). Drives + ``intl.accept_languages`` → both ``navigator.language``/``languages`` + AND the q-valued ``Accept-Language`` header (the patched binary builds + the header from the pref, never from the raw Playwright locale override, + so the two never diverge — see nsHttpHandler STEALTHFOX note). timezone: IANA zone (e.g. ``"America/New_York"``) — used as-is when set, the only way to force a specific zone. ``""`` (default) or ``"auto"`` ALWAYS resolves from the egress IP: @@ -199,11 +206,17 @@ class InvisiblePlaywright: _geo = prepare_session_geo(self._timezone, self._proxy) self._timezone = _geo.timezone self._webrtc_egress_ip = _geo.egress_ip + # Geo-aware locale: "auto" derives the language from the egress country (reusing + # the egress IP already discovered above), like timezone="auto". Keeps the browser + # language consistent with the proxy's country instead of a fixed en-US. + if (self._locale or "").strip().lower() == "auto": + from ._geo import resolve_session_locale + self._locale = resolve_session_locale(_geo.egress_ip, self._proxy) executable = self._binary_path or ensure_binary() prefs = self._build_prefs() playwright_proxy = _configure_proxy_shared(self._proxy, prefs) pw_headless = self._resolve_headless() - env = self._build_env() + env = self._build_env(prefs) try: self._pw = sync_playwright().start() @@ -358,7 +371,7 @@ class InvisiblePlaywright: prefs["stealthfox.humanize.maxTime"] = str(self._humanize_max_seconds()) return prefs - def _build_env(self) -> Dict[str, str]: + def _build_env(self, prefs: Dict[str, Any]) -> Dict[str, str]: """Env vars passed to the Firefox subprocess. ``TZ`` tunes the libc clock the content process reads for @@ -369,11 +382,24 @@ class InvisiblePlaywright: a synthetic srflx candidate matching the proxy egress IP, avoiding the StaticPref IPC propagation timing issue between parent and socket processes. + ``STEALTHFOX_FONTLIST`` / ``STEALTHFOX_SYSTEMUI`` carry the font + allow-list + system-ui family for the SAME reason: the binary reads + them at the gfxPlatformFontList constructor (process start), but + Playwright delivers firefox_user_prefs over the juggler protocol + AFTER start — too late for the font list ctor. The env var is present + at start and inherited by content processes, so the allow-list is + applied on every host (without it, host fonts leak on Linux/macOS). """ import os as _os env = _os.environ.copy() if self._timezone: env["TZ"] = _tz_env(self._timezone) + fontlist = prefs.get("zoom.stealth.font.fontlist") + if fontlist: + env["STEALTHFOX_FONTLIST"] = fontlist + system_ui = prefs.get("zoom.stealth.font.system_ui") + if system_ui: + env["STEALTHFOX_SYSTEMUI"] = system_ui # WebRTC srflx override: feed nICEr's nr_stealth_bridge the proxy egress # IP so the srflx candidate matches the proxy (not the real host the # UDP STUN would otherwise leak). An explicit env var set by the caller diff --git a/src/invisible_playwright/prefs.py b/src/invisible_playwright/prefs.py index d50bd2d..f06778e 100644 --- a/src/invisible_playwright/prefs.py +++ b/src/invisible_playwright/prefs.py @@ -170,39 +170,6 @@ _WIN_VOICES = ",".join([ ]) -# ────────────────────────────────────────────────────────────────────── -# 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 = ( - # GENERIC factors are calibrated to FP Pro's font_preferences probe (NOT the 72px named-probe - # scale — verified 2026-06-18: recalibrating to the named-probe scale broke them, mono 121.55 - # -> 112.4; the original values are correct for font_preferences). DejaVu/Liberation generics - # vs Windows targets: serif 0.920, sans 0.889, monospace 1.000, system-ui 0.910. - "serif|0.920,sans-serif|0.889,monospace|1.000," - "system-ui|0.910,cursive|0.932,fantasy|0.812," -) - -# Calibration reference string for the C++ self-calibrating absolute-width path. -# MUST be byte-identical to the probe the font_pool widths were measured with -# (72px canvas measureText) and to the binary's zoom.stealth.font.calib_ref -# default. Set explicitly so correctness never depends on the binary default. -_FONT_CALIB_REF = "mmmmwwwwiiiillloooMMMMWWWW0123456789 The quick brown fox" -# NOTE: there is intentionally NO per-OS collapse-base table here anymore. -# The C++ font hook self-calibrates: `font_pool` stores the UNIVERSAL real -# Windows measureText width per font and the binary divides it by the host's -# own collapse base (gfxTextRun::StealthCollapseBase, summed from the list-head -# font's per-glyph advances over zoom.stealth.font.calib_ref). So the SAME -# stored value yields the exact Windows width on Windows, Linux AND macOS with -# nothing to measure per platform. (Was: _COLLAPSE_BASE = {win32:1530, linux:2400} -# + a darwin TODO — removed 2026-06-18 when the self-calibrating C++ landed.) - - # ────────────────────────────────────────────────────────────────────── # Baseline — applied to every session regardless of Profile. # ────────────────────────────────────────────────────────────────────── @@ -374,9 +341,37 @@ _BASELINE: Dict[str, Any] = { # 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, + # Canvas substitution (Option B for canvas) — replace pixels with hash(seed,idx), + # uniform-skip (red-box exact, masking-safe) + full overwrite. Makes the canvas + # render a pure function of (seed) = HOST-INDEPENDENT (kills the DWrite-vs-FreeType + # text-raster leak: Canvas Hash + Font hash were the residual Win!=Linux signals). + # ON by default (paired with webgl.substitute_pixels). + "zoom.stealth.canvas.substitute_pixels": True, + + # WebGL substitution (Option B) — replace readback/snapshot RGB with + # hash(seed,idx), endpoint-preserving. Makes the WebGL render hash a pure + # function of (seed, dims) = HOST-INDEPENDENT, so no per-host hw_seed + # calibration is needed (the gamma path was per-host: NVIDIA/Arc-on-Win clean + # seeds went dirty on the Linux GL backend). ON by default. + "zoom.stealth.webgl.substitute_pixels": True, + + # WebGPU presence consistency. Firefox enables dom.webgpu.enabled by default on + # Windows/Mac-ARM but NOT on Linux/Mac-x64. We ALWAYS claim Windows, so force it ON + # on every host: a Windows FF MUST expose navigator.gpu (object); a Linux host leaving + # it undefined while the UA says Windows is an inconsistency tell (RE 2026-06-22: + # has_gpu was object on Win, undefined on WSL). adapter.info is empty (FF privacy + # default) so no GPU-name leak; requestAdapter may be null on a GPU-less host, which + # is itself plausible for a real Windows machine. + "dom.webgpu.enabled": True, + + # Audio fingerprint noise OFF. RE 2026-06-22: the per-session OfflineAudioContext + # noise (gated by hw_seed) was THE dominant driver of FP Pro tampering_ml on Windows + # — b005 Win dropped 0.4349 -> 0.0564 with audio noise alone disabled (canvas_text/ + # emoji unchanged, so they were a red-herring). The audio value is already host-indep + # AND identical to a real FF's canonical OfflineAudioContext sum, so a fixed (un-noised) + # audio is NOT a linking signal (every real FF has the same value) — removing the noise + # matches real Firefox and clears the tampering flag. + "zoom.stealth.audio.fp_noise": False, # Navigator identity (locked to Windows Firefox 150). **_NAVIGATOR_OVERRIDES, @@ -452,59 +447,17 @@ _WIN_VIRT_DESKTOP_WORKAROUNDS: Dict[str, Any] = { # ────────────────────────────────────────────────────────────────────── def _accept_language(locale: str) -> str: + # ", " — the desktop-default shape (e.g. "en-US, en"). Firefox expands it + # to navigator.languages=["en-US","en"] AND (via the patched binary) the q-valued header + # "en-US,en;q=0.5". The patched nsHttpHandler (STEALTHFOX, RE 2026-06-23) builds the + # Accept-Language header from THIS pref even when juggler sets a per-context locale + # override, so header and navigator.languages stay consistent 2/2 — the most authentic + # (real desktop) form. Supersedes the 2026-06-22 single-tag workaround. lang = locale.replace("_", "-") base = lang.split("-")[0] return f"{lang}, {base}" if base != lang else lang -def _font_metrics_for_platform(profile_metrics: str) -> str: - """Return ``zoom.stealth.font.metrics`` value. - - The C++ whitelist hook (``gfxPlatformFontList::FindAndAddFamiliesLocked``) - backs EVERY whitelisted *named* family with the list-head family on every - platform. Without per-font width factors, that means each named font - (Arial, Times New Roman, Courier New, …) renders with identical glyphs and - collapses to a SINGLE canvas ``measureText`` width — a non-physical - 1-distinct-width result that strict JS-sensor anti-bots flag via their - font probe. The per-font factors in ``profile_metrics`` - (``arial|0.978,arial black|1.168,…``) spread the fabricated families back - to distinct, realistic, deterministic-per-seed widths, so we apply them on - EVERY platform (previously suppressed on Windows/mac, which left the - collapse in place — only the CSS-generic vector, which FP Pro probes, was - ever correct there). - - These factors only key *named* families. CSS generics - (serif/sans-serif/monospace/system-ui) bypass the whitelist entirely and - render at the host's native widths, so they are never present in - ``profile_metrics`` and stay unfactored — FP Pro's ``font_preferences`` - probe (which measures the generics) is unaffected. That is also why - applying named-font factors here does NOT distort the canonical generic - widths. - - Linux ADDITIONALLY needs generic-family compensation - (``_LINUX_GENERIC_FONT_FACTORS``) because DejaVu/Liberation generics render - wider/narrower than the Windows widths the spoofed profile claims; on - Windows/mac the generics already render native, so no generic compensation - is applied — only the named-font factors. - """ - if not profile_metrics: - return "" - # profile_metrics arrives as "name|,..." (host-independent - # absolute widths, e.g. "arial|2256.7"). We pass them through UNCHANGED: the C++ - # hook treats a value >= 10 as an ABSOLUTE target measureText width and divides it - # by the host's own collapse base (self-calibrating), so the SAME value yields the - # exact Windows width on Windows, Linux AND macOS. No per-OS division here. - metrics = profile_metrics - # Linux ADDITIONALLY needs CSS-generic compensation (DejaVu/Liberation generics - # render wider/narrower than Windows). These are multiplicative FACTORS (< 10), - # calibrated to FP Pro's font_preferences probe; the C++ hook treats values < 10 as - # factors. Generics bypass the whitelist/collapse so they are NOT self-calibrated. - # Windows/mac generics render native -> no compensation. - if sys.platform.startswith("linux"): - return _LINUX_GENERIC_FONT_FACTORS + metrics - return metrics - - def translate_profile_to_prefs( profile: Profile, *, @@ -606,15 +559,37 @@ def translate_profile_to_prefs( 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 "" - ) - # Reference string the binary sums the collapsed font's advances over to - # self-calibrate the per-host collapse base (turns the absolute widths in - # `metrics` into the right factor on any OS). See _font_metrics_for_platform. - prefs["zoom.stealth.font.calib_ref"] = _FONT_CALIB_REF + # Fonts — real bundled fonts (no collapse). The binary ships the real + # Windows font files in /fonts and loads them via MOZ_BUNDLED_FONTS, so + # glyphs are genuine on every host. We expose this profile's family set via + # the per-profile fontlist, which the binary applies to the native system + # font allow-list AT CONSTRUCTION (no runtime rebuild → no scan stall), and + # force the system-ui generic to Segoe UI. profile.fonts is the _fpforge + # sample (core always + a conditioned optional subset). Per-profile metric + # uniqueness comes from the shared fpp.hw_seed jitter in the HarfBuzz shaper + # (set with the other fpp prefs), not from fabricated widths. + prefs["zoom.stealth.font.fontlist"] = ",".join(profile.fonts) + prefs["zoom.stealth.font.system_ui"] = "Segoe UI" + + # Activate the bundled real-Windows fonts (MOZ_BUNDLED_FONTS / /fonts). + prefs["gfx.bundled-fonts.activate"] = 1 + # Point the CSS generics at Windows defaults. On a Windows HOST Firefox's + # built-in name-lists already resolve to these (and they're in the fontlist + # so they survive the allow-list); but on a non-Windows host (Linux/Mac) the + # built-in defaults name host fonts (DejaVu/Liberation/…) which the allow-list + # hides — so the generics would collapse. Setting them explicitly keeps the + # generics resolving to the bundled Windows families on EVERY host (system-ui + # is forced to Segoe UI by the C++ hook above, so it is not listed here). + prefs["font.name-list.serif.x-western"] = "Times New Roman" + prefs["font.name-list.sans-serif.x-western"] = "Arial" + prefs["font.name-list.monospace.x-western"] = "Consolas" + prefs["font.name-list.sans-serif.ja"] = "Yu Gothic UI" + prefs["font.name-list.serif.ja"] = "Yu Gothic UI" + prefs["font.name-list.sans-serif.ko"] = "Malgun Gothic" + prefs["font.name-list.serif.ko"] = "Malgun Gothic" + prefs["font.name-list.sans-serif.zh-CN"] = "Microsoft YaHei UI" + prefs["font.name-list.sans-serif.zh-TW"] = "Microsoft JhengHei UI" + prefs["font.name-list.sans-serif.zh-HK"] = "Microsoft JhengHei UI" # UI / dark mode + Windows colors palette (only when light theme). prefs["ui.systemUsesDarkTheme"] = int(profile.dark_theme) @@ -628,6 +603,15 @@ def translate_profile_to_prefs( prefs["general.useragent.locale"] = lang prefs["intl.locale.requested"] = lang prefs["privacy.spoof_english"] = 0 + # juggler.locale.override seeds the BrowsingContext LanguageOverride FIELD in + # the parent process (BrowsingContext::Attach), whose DidSet drives BOTH + # navigator.languages (the full list) AND the realm Intl default locale (the + # primary tag it extracts) — so Intl.DateTimeFormat / NumberFormat / + # toLocaleString follow the locale, not just the Accept-Language header. Seed + # it with the full Accept-Language list so navigator.languages stays the + # desktop-default 2 elements (["fr-FR","fr"]); the C++ DidSet takes "fr-FR" + # for Intl. Mirrors juggler.timezone.override; the SOLE source of truth. + prefs["juggler.locale.override"] = _accept_language(locale) if timezone: # juggler.timezone.override is the SOLE source of truth read by the C++ diff --git a/tests/test_canvas_render_stealth.py b/tests/test_canvas_render_stealth.py index e0c7ba1..838401a 100644 --- a/tests/test_canvas_render_stealth.py +++ b/tests/test_canvas_render_stealth.py @@ -1,19 +1,17 @@ -"""Canvas / WebGL render-stealth regression tests (binary-level, 2026-06-18). +"""Canvas / WebGL render-stealth regression test (binary-level, 2026-06-18). -Two patched-binary behaviours that must never regress, both needed for the +Guards a patched-binary behaviour that must never regress, needed for the fingerprint to look like a real Windows browser to FOSS detectors (CreepJS, -FingerprintJS, BrowserLeaks) and to image-dedup font probes / fixed-hash -reference checks: +FingerprintJS, BrowserLeaks) and fixed-hash reference checks: - 1. Per-font canvas distinctness — whitelisted named fonts are backed by the - host list-head glyphs (so measureText widths are host-independent), but each - must still rasterise to a DISTINCT image at tiny probe sizes. Otherwise an - image-dedup font probe collapses them to ~1 name and the reported font set - looks fabricated. (C++: per-font sub-pixel draw offset in DrawText.) - 2. Solid WebGL readback purity under render-noise — a fixed solid-colour WebGL - readback (which reference checks hash against a universal constant) must stay - byte-exact even with per-seed render-noise enabled, while high-entropy - renders stay noised. (C++: render-noise skips near-uniform WebGL readbacks.) + Solid WebGL readback purity under render-noise — a fixed solid-colour WebGL + readback (which reference checks hash against a universal constant) must stay + byte-exact even with per-seed render-noise enabled, while high-entropy + renders stay noised. (C++: render-noise skips near-uniform WebGL readbacks.) + +(Per-font canvas distinctness is no longer guarded here: the font-collapse + +per-font draw offset were removed on 2026-06-20 in favour of real bundled +Windows fonts, which rasterise to distinct images by nature.) Runs against about:blank, no network/proxy. Part of the e2e release gate. Run: pytest tests/test_canvas_render_stealth.py -m e2e -v @@ -23,34 +21,6 @@ from __future__ import annotations import pytest from invisible_playwright import InvisiblePlaywright -from invisible_playwright import prefs as _prefs -from invisible_playwright._fpforge import generate_profile - -# Diverse-codepoint probe string — maximises per-font rendering differences, the -# way an image-dedup font probe drives a tiny canvas. -_PROBE = ("\U0001f6cd1>'`amlρiюदे來˦" - "\U00025578に◌\U0002003eԩԨ") - - -def _named_fonts(limit: int = 30) -> list[str]: - """The whitelisted NAMED fonts (absolute collapse-target width >= 10) for the - test seed — these are the ones the per-font offset must keep distinct.""" - prof = generate_profile(42) - metrics = _prefs._font_metrics_for_platform(prof._raw.get("font_metrics", "") or "") - out: list[str] = [] - for ent in metrics.split(","): - name, _, val = ent.partition("|") - if not val: - continue - try: - if float(val.replace("px", "")) >= 10.0: - out.append(name) - except ValueError: - pass - return out[:limit] - - -_FONTS = _named_fonts() @pytest.fixture(scope="module") @@ -68,34 +38,6 @@ def noised_page(firefox_binary): yield p -@pytest.mark.e2e -def test_named_fonts_render_distinct_canvas_images(noised_page): - """Each whitelisted named font must produce a DISTINCT tiny-canvas image so an - image-dedup font probe keeps every name. Regression: without the per-font draw - offset all whitelisted fonts share the list-head glyphs -> ~1-2 distinct - images -> degenerate detected-font set.""" - assert len(_FONTS) >= 10, "expected a non-trivial named-font whitelist to probe" - distinct = noised_page.evaluate( - """(args) => { - const [fonts, V] = args; - const c = document.createElement('canvas'); c.width = 90; c.height = 12; - const d = c.getContext('2d'); d.fillStyle = 'red'; - const seen = new Set(); - for (const f of fonts) { - d.clearRect(0, 0, 90, 12); - d.font = 'normal 4px "' + f + '"'; - d.fillText(V, 5, 8); - seen.add(c.toDataURL()); - } - return seen.size; - }""", - [_FONTS, _PROBE], - ) - # broken (offset removed) collapses to ~1-2; require nearly all distinct. - assert distinct >= len(_FONTS) - 2, \ - f"only {distinct}/{len(_FONTS)} distinct font images (per-font offset regressed?)" - - @pytest.mark.e2e def test_solid_webgl_readback_stays_pure_under_noise(noised_page): """A solid-colour WebGL readback must remain byte-exact (only {0,255}) with diff --git a/tests/test_integration.py b/tests/test_integration.py index 4ba29c2..3e4ad81 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -42,8 +42,8 @@ _REQUIRED_PREFS_KEYS = ( "media.encoder.webm.enabled", "media.mediasource.webm.enabled", "media.mediasource.mp4.enabled", - "zoom.stealth.font.whitelist", - "zoom.stealth.font.metrics", + "zoom.stealth.font.fontlist", + "zoom.stealth.font.system_ui", "ui.systemUsesDarkTheme", "intl.accept_languages", "general.useragent.locale", @@ -197,23 +197,26 @@ def test_http_proxy_returned_unchanged_no_socks_mutations(): # ────────────────────────────────────────────────────────────────────── -# IT7: profile.fonts reaches prefs as a comma-joined whitelist +# IT7: profile.fonts reaches prefs as a comma-joined fontlist # ────────────────────────────────────────────────────────────────────── @pytest.mark.integration -def test_profile_fonts_propagate_to_prefs_whitelist(): +def test_profile_fonts_propagate_to_prefs_fontlist(): """IT7 — every font in ``profile.fonts`` appears in the comma-joined - ``zoom.stealth.font.whitelist`` pref, in order.""" + ``zoom.stealth.font.fontlist`` pref, in order. The binary applies this + list to the native system font allow-list at construction; system-ui is + forced to Segoe UI.""" profile = generate_profile(seed=42) prefs = translate_profile_to_prefs(profile) assert profile.fonts, "fixture seed=42 produced empty fonts list" - whitelist = prefs["zoom.stealth.font.whitelist"] - assert isinstance(whitelist, str) - assert whitelist == ",".join(profile.fonts) + fontlist = prefs["zoom.stealth.font.fontlist"] + assert isinstance(fontlist, str) + assert fontlist == ",".join(profile.fonts) for font in profile.fonts: - assert font in whitelist + assert font in fontlist + assert prefs["zoom.stealth.font.system_ui"] == "Segoe UI" # ────────────────────────────────────────────────────────────────────── @@ -357,23 +360,3 @@ def test_linux_msaa_pin_propagates_through_pipeline(monkeypatch): assert prefs["webgl.msaa-force"] is True -# ────────────────────────────────────────────────────────────────────── -# IT13 (extra): Linux font metrics receive the GTK/DejaVu compensation -# block. End-to-end check that ``_LINUX_GENERIC_FONT_FACTORS`` is -# prepended to the per-font metrics string sampled from the profile. -# ────────────────────────────────────────────────────────────────────── - - -@pytest.mark.integration -def test_linux_font_metrics_include_generic_factors(monkeypatch): - """IT13 — on Linux the font metrics pref starts with the generic - width-scale factors (GTK/DejaVu compensation) so glyph widths match - Windows. Without this, Linux sessions leak via metric drift.""" - from invisible_playwright.prefs import _LINUX_GENERIC_FONT_FACTORS - - monkeypatch.setattr(sys, "platform", "linux") - profile = generate_profile(seed=42) - prefs = translate_profile_to_prefs(profile) - - metrics = prefs["zoom.stealth.font.metrics"] - assert metrics.startswith(_LINUX_GENERIC_FONT_FACTORS) diff --git a/tests/test_launcher_helpers.py b/tests/test_launcher_helpers.py index 590736a..1847001 100644 --- a/tests/test_launcher_helpers.py +++ b/tests/test_launcher_helpers.py @@ -181,7 +181,7 @@ def test_default_context_omits_locale_when_empty(): def test_build_env_injects_webrtc_egress_when_discovered(): ip = InvisiblePlaywright(seed=42) ip._webrtc_egress_ip = "203.0.113.9" # what __enter__ resolves behind a proxy - env = ip._build_env() + env = ip._build_env({}) assert env["STEALTHFOX_WEBRTC_PUBLIC_IP"] == "203.0.113.9" assert env["STEALTHFOX_WEBRTC_DISABLE_IPV6"] == "1" @@ -191,7 +191,7 @@ def test_build_env_no_webrtc_keys_without_proxy(monkeypatch): monkeypatch.delenv("STEALTHFOX_WEBRTC_PUBLIC_IP", raising=False) ip = InvisiblePlaywright(seed=42) ip._webrtc_egress_ip = None # no proxy → real STUN already truthful - env = ip._build_env() + env = ip._build_env({}) assert "STEALTHFOX_WEBRTC_PUBLIC_IP" not in env assert "STEALTHFOX_WEBRTC_DISABLE_IPV6" not in env @@ -201,6 +201,29 @@ def test_build_env_caller_env_override_wins(monkeypatch): monkeypatch.setenv("STEALTHFOX_WEBRTC_PUBLIC_IP", "198.51.100.5") ip = InvisiblePlaywright(seed=42) ip._webrtc_egress_ip = "203.0.113.9" # auto-discovered - env = ip._build_env() + env = ip._build_env({}) assert env["STEALTHFOX_WEBRTC_PUBLIC_IP"] == "198.51.100.5" # caller wins assert env["STEALTHFOX_WEBRTC_DISABLE_IPV6"] == "1" + + +@pytest.mark.unit +def test_build_env_injects_font_list_and_system_ui(): + # The binary reads these at the gfxPlatformFontList constructor (process + # start); Playwright delivers firefox_user_prefs over juggler AFTER start, so + # the env var is the only at-construction channel. Without it host fonts leak + # on Linux/macOS (the wrapper's pref-only delivery was a cross-OS gap). + ip = InvisiblePlaywright(seed=42) + env = ip._build_env({ + "zoom.stealth.font.fontlist": "arial,calibri,segoe ui", + "zoom.stealth.font.system_ui": "Segoe UI", + }) + assert env["STEALTHFOX_FONTLIST"] == "arial,calibri,segoe ui" + assert env["STEALTHFOX_SYSTEMUI"] == "Segoe UI" + + +@pytest.mark.unit +def test_build_env_no_font_keys_when_absent(): + ip = InvisiblePlaywright(seed=42) + env = ip._build_env({}) + assert "STEALTHFOX_FONTLIST" not in env + assert "STEALTHFOX_SYSTEMUI" not in env diff --git a/tests/test_prefs.py b/tests/test_prefs.py index 609841b..2cec2c2 100644 --- a/tests/test_prefs.py +++ b/tests/test_prefs.py @@ -5,9 +5,7 @@ import pytest from invisible_playwright._fpforge import generate_profile from invisible_playwright.prefs import ( - _LINUX_GENERIC_FONT_FACTORS, _accept_language, - _font_metrics_for_platform, _WIN_LIGHT_COLORS, translate_profile_to_prefs, ) @@ -82,29 +80,6 @@ def test_accept_language_underscore_normalized(): assert _accept_language("pt_BR") == "pt-BR, pt" -# ────────────────────────────────────────────────────────────────────── -# _font_metrics_for_platform -# ────────────────────────────────────────────────────────────────────── - - -@pytest.mark.unit -def test_font_metrics_windows_applies_named_factors(monkeypatch): - # FM2: Windows/mac apply the per-NAMED-font factors (so whitelisted named - # families don't collapse to the list-head width on the canvas measureText - # path), but WITHOUT the Linux generic-family compensation (generics bypass - # the whitelist and render native there). - monkeypatch.setattr(sys, "platform", "win32") - out = _font_metrics_for_platform("Arial|1.0,Verdana|0.9,") - assert out == "Arial|1.0,Verdana|0.9," - assert "sans-serif|" not in out # no generic compensation on Windows - - -@pytest.mark.unit -def test_font_metrics_empty_input_returns_empty(): - # FM3: Empty input always returns "" regardless of platform. - assert _font_metrics_for_platform("") == "" - - # ────────────────────────────────────────────────────────────────────── # Platform-specific GPU / MSAA (Windows) # ────────────────────────────────────────────────────────────────────── @@ -385,34 +360,6 @@ def test_lan_ip_seed_zero_has_no_zero_octets(): # ────────────────────────────────────────────────────────────────────── -@pytest.mark.unit -def test_font_metrics_linux_prepends_generic_factors(monkeypatch): - # FM1: Linux prepends the GTK/DejaVu compensation block to the - # per-font metrics string sampled from the profile. - monkeypatch.setattr(sys, "platform", "linux") - out = _font_metrics_for_platform("Arial|1.0,Verdana|0.9,") - assert out.startswith(_LINUX_GENERIC_FONT_FACTORS) - assert out.endswith("Arial|1.0,Verdana|0.9,") - - -@pytest.mark.unit -def test_font_metrics_linux_empty_input_returns_empty(monkeypatch): - # FM1b: even on Linux, empty profile metrics short-circuits before - # the prepend so we never emit a metrics pref containing only the - # generic block (which would surface as a tampering signal). - monkeypatch.setattr(sys, "platform", "linux") - assert _font_metrics_for_platform("") == "" - - -@pytest.mark.unit -def test_font_metrics_linux2_variant_uses_linux_branch(monkeypatch): - # FM1c: ``sys.platform`` can be ``linux2`` on older Pythons / odd - # WSL builds. ``startswith("linux")`` accepts both. - monkeypatch.setattr(sys, "platform", "linux2") - out = _font_metrics_for_platform("Verdana|0.9,") - assert out.startswith(_LINUX_GENERIC_FONT_FACTORS) - - @pytest.mark.unit def test_gpu_renderer_set_from_profile_on_linux(monkeypatch): # PG1: on Linux (as on EVERY host) we apply the camoufox-derived Windows-ANGLE GPU persona, diff --git a/tests/test_sampler.py b/tests/test_sampler.py index b201c7f..e9af219 100644 --- a/tests/test_sampler.py +++ b/tests/test_sampler.py @@ -216,12 +216,15 @@ def test_screen_tier_4200x2000_is_ultrawide_via_width_branch(): # ── derive_font_prefs / derive_font_whitelist ─────────────────────────── @pytest.mark.unit -def test_derive_font_prefs_returns_whitelist_and_metrics_keys(): - """FP1 [HAPPY]: result has the two expected string keys.""" +def test_derive_font_prefs_returns_whitelist_key(): + """FP1 [HAPPY]: result is a single-key dict with the font family list. + + The per-family ``metrics`` string was removed on 2026-06-20: fonts now + render from the bundled real Windows files (genuine widths) and per-session + metric uniqueness comes from the HarfBuzz jitter, not fabricated factors.""" out = derive_font_prefs("integrated_modern", random.Random(42)) - assert set(out.keys()) == {"whitelist", "metrics"} + assert set(out.keys()) == {"whitelist"} assert isinstance(out["whitelist"], str) - assert isinstance(out["metrics"], str) @pytest.mark.unit @@ -249,15 +252,6 @@ def test_derive_font_prefs_unknown_class_falls_back_to_integrated_modern(): assert fallback == expected -@pytest.mark.unit -def test_derive_font_prefs_metrics_and_whitelist_are_coherent(): - """FP5 [ECP]: every name in whitelist has a metrics entry and vice versa.""" - out = derive_font_prefs("mid_range", random.Random(99)) - wl_names = out["whitelist"].split(",") - metrics_names = [s.split("|", 1)[0] for s in out["metrics"].split(",")] - assert wl_names == metrics_names - - @pytest.mark.unit def test_derive_font_prefs_whitelist_alphabetically_sorted(): """FP6 [ECP]: whitelist names are sorted (ordering invariant for stable dedup).""" @@ -280,10 +274,12 @@ def test_derive_font_whitelist_legacy_shim_matches_dict_form(): # machine never lacks them, so a session that drops one advertises a font set that # doesn't match any real Windows profile (image-dedup font probes then report a # short/degenerate name list → server-side OS-font-set checks fail). Calibri in -# particular sat in `optional` (a bug); these five caused the detected set to come -# up short on some seeds. Regression guard for the 2026-06-18 optional→core move. +# particular sat in `optional` (a bug); these caused the detected set to come up +# short on some seeds. Regression guard for the 2026-06-18 optional→core move. +# NB: the exact Win11 family is "franklin gothic medium" (there is no bare +# "franklin gothic" family); the 2026-06-20 bundle reconciliation uses real names. _STANDARD_WINDOWS_FONTS = [ - "calibri", "franklin gothic", "gadugi", "javanese text", "myanmar text", + "calibri", "franklin gothic medium", "gadugi", "javanese text", "myanmar text", ] _ALL_GPU_CLASSES = [ "integrated_old", "integrated_modern", "mid_range", "high_end", @@ -294,16 +290,14 @@ _ALL_GPU_CLASSES = [ @pytest.mark.unit @pytest.mark.parametrize("gpu_class", _ALL_GPU_CLASSES) def test_standard_windows_fonts_always_present_every_class_and_seed(gpu_class): - """FP7 [regression]: the standard-Windows fonts appear in BOTH whitelist and - metrics for every gpu_class across many seeds (i.e. they are core, not - profile-optional). Guards against a standard font silently becoming optional.""" + """FP7 [regression]: the standard-Windows fonts appear in the whitelist for + every gpu_class across many seeds (i.e. they are core, not profile-optional). + Guards against a standard font silently becoming optional.""" for seed in range(40): out = derive_font_prefs(gpu_class, random.Random(seed)) wl = set(out["whitelist"].split(",")) - metrics_names = {s.split("|", 1)[0] for s in out["metrics"].split(",")} for font in _STANDARD_WINDOWS_FONTS: assert font in wl, f"{font!r} missing from whitelist (class={gpu_class}, seed={seed})" - assert font in metrics_names, f"{font!r} missing from metrics (class={gpu_class}, seed={seed})" @pytest.mark.unit @@ -320,33 +314,13 @@ def test_standard_windows_fonts_are_in_core_pool(): @pytest.mark.unit @pytest.mark.parametrize("gpu_class", _ALL_GPU_CLASSES) def test_derive_font_prefs_no_duplicate_families(gpu_class): - """FP9 [regression]: no family appears twice in whitelist/metrics, even when a + """FP9 [regression]: no family appears twice in the whitelist, even when a profile's optional list also names a core font. Guards the dedup in - derive_font_prefs (a duplicate family would emit a malformed pref pair).""" + derive_font_prefs (a duplicate family would emit a malformed list).""" for seed in range(30): out = derive_font_prefs(gpu_class, random.Random(seed)) wl = out["whitelist"].split(",") - metrics_names = [s.split("|", 1)[0] for s in out["metrics"].split(",")] assert len(wl) == len(set(wl)), f"duplicate in whitelist (class={gpu_class}, seed={seed})" - assert len(metrics_names) == len(set(metrics_names)), \ - f"duplicate in metrics (class={gpu_class}, seed={seed})" - - -@pytest.mark.unit -@pytest.mark.parametrize("gpu_class", _ALL_GPU_CLASSES) -def test_derive_font_prefs_named_fonts_emit_absolute_widths(gpu_class): - """FP10 [regression]: every emitted metrics value is a positive number; named - (non-generic) fonts carry an ABSOLUTE collapse-target width (>= 10), which the - binary self-calibrates per host. A value < 10 here would mean a font slipped - through as a bare multiplicative factor and would render at the wrong width.""" - out = derive_font_prefs(gpu_class, random.Random(3)) - for entry in out["metrics"].split(","): - name, _, val = entry.partition("|") - v = float(val.replace("px", "")) - assert v > 0.0, f"non-positive metrics value for {name!r}" - # the standard named fonts must be absolute (collapse-target) widths - if name in _STANDARD_WINDOWS_FONTS: - assert v >= 10.0, f"{name!r} emitted as factor {v} (<10), expected absolute width" # ── Forge / sample ────────────────────────────────────────────────────── @@ -364,7 +338,7 @@ _EXPECTED_KEYS = { "av1_enabled", "webm_encoder_enabled", "mediasource_webm", "mediasource_mp4", "webspeech_synth", "storage_quota_mb", "dark_theme", - "font_whitelist", "font_metrics", + "font_whitelist", } @@ -453,10 +427,9 @@ def test_forge_sample_avail_h_defaults_to_h_minus_40_when_missing(monkeypatch): @pytest.mark.unit def test_forge_sample_includes_font_keys(): - """FS9 [ECP]: font_whitelist + font_metrics present and non-empty.""" + """FS9 [ECP]: font_whitelist present and non-empty (the joined family list).""" out = sample(42) assert out["font_whitelist"] - assert out["font_metrics"] assert "," in out["font_whitelist"] # at least the core fonts joined