67 Commits
Author SHA1 Message Date
Jake WriterandClaude Opus 5.5 ec37d722b7 Update to Firefox 156.0.1 (#806)
* Pin Firefox 156.0.1

Camoufox beta.32 is built on Firefox 156.0.1 (was 152.0.4).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* port playwright patch to Firefox 156

- nsGeolocationService became mozilla::GeolocationService in
  toolkit/components/geolocation/GeolocationService.{h,cpp}; the override
  (Init(isOverride), IsOverride, the UpdateAccuracy early return) moves there.
- OnLinkClick now dispatches through OnLinkClickWithLoadState, so the
  juggler-link-click notification is sent just before that call.
- nsWindowWatcher::CalculateChromeFlagsForContent was restructured around
  ShouldOpenPopup; the explicit-size flag is added on the popup branch.
- JUGGLER_WINDOW_EXPLICIT_SIZE was 1<<22, which Firefox 156 assigns to
  CHROME_DOCUMENT_PIP. The hunk applied cleanly and would have flagged every
  sized popup as a document PiP window; it moves to the free bit 1<<26, as
  upstream Playwright did.
- EnterprisePolicies _chooseProvider became _buildProvider and the remote
  settings getter shouldSkipRemoteActivityDueToTests became
  shouldSkipRemoteActivity.
- Port upstream's WorkerScriptLoader fix (microsoft/playwright#42565): from
  Firefox 153 a reload while a large worker script is still compiling crashes
  the content process (Bugzilla 2044428).
- Drop the GetAcceptLanguages argument swap. Firefox 156 adds callers that use
  the native signature (worker language override), and the swap changed no
  behaviour: every caller passes null for an empty override.
- The HeadlessCompositorWidget and nsFilePicker include hunks are rebased; the
  file picker already includes gfxPlatform.h.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* port playwright leak fixes to Firefox 156

Navigator::Webdriver() now asks Marionette and the remote agent for
GetIsBrowserAutomationRunning; the body is still replaced with 'return false'.
The policy provider hunk applied cleanly but restored _chooseProvider(), which
Firefox 156 renamed to _buildProvider() -- policy loading would have thrown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* juggler: follow Firefox 156 API changes

- Firefox 156 enforces dom.jsipc.check_safeForUntrustedWebProcess: a
  JSWindowActor without safeForUntrustedWebProcess is never instantiated in a
  web content process, so JugglerFrame would not load in any page.
- ContextualIdentityService moved to a moz-src:/// URL.
- Debugger.onPromiseSettled was removed (bug 2044167). Pending promises are
  settled the way upstream Juggler does now: reactions in the debuggee hit a
  debugger statement, and onDebuggerStatement sweeps the pending set.
  Without this every evaluate returning a pending promise never resolves.
- The worker debugger manager is a singleton service; createInstance now
  fails in the content process, so FrameTree uses getService like upstream.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* port anti-font-fingerprinting to Firefox 156

gfxTextRun.cpp's include block was re-sorted, and nsPIDOMWindow.h no longer
pulls in nsPIDOMWindowInlines.h, so the per-context font group lookup needs it
explicitly to link. A whitespace-only hunk that no longer applied is dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* port contentaccessible-parity to Firefox 156

Firefox 156 moved DevelopmentHelpers.init() out of browser-init.js into a
browser-window-delayed-startup category entry in BrowserComponents.manifest,
so that entry goes with the file. The stock file-set manifest is re-recorded
from the official Firefox 156.0.1 linux-x86_64 build (sha256 7405c048...);
the guard refuses to compare across versions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* port cross-process-storage to Firefox 156

RecvCreateGMPService's tail changed; the Roverfox storage IPC handlers are
added after it unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* port librewolf dbus_name to Firefox 156

The D-Bus fallback bus and object path names moved; the same strings are
renamed at their new locations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* port debugger-invisible-to-content to Firefox 156

Firefox 156 removed asm.js compilation and with it Debugger
allowUnobservedAsmJS and the DebuggerObservesAsmJS realm bit. The wasm side
is unchanged, so invisibleToContent now keeps wasm unobserved, and the realm
flag moves down to the freed bit. The set of content-observable isDebuggee()
checks is the same as in 152 (Promise, async stack capture, throw stacks).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* port fingerprint-injection to Firefox 156

nsGlobalWindowInner::GetInnerWidth/GetInnerHeight gained a CallerType
parameter; the MaskConfig override is applied at the top of the new
signatures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* port librewolf firefox-view to Firefox 156

Firefox 156 already made firefox-view-button a CustomizableWidget that is not
in the default tab strip, and removes it from existing profiles. What still
applies is dropping the old version-18 migration that would re-add it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* port font-hijacker to Firefox 156

Include blocks were re-sorted. LookupLocalFont now returns
already_AddRefed<gfxFontEntry> and the user font set holds a RefPtr, so the
macOS family-name fix builds the entry with MakeRefPtr and the local() mask
check just drops the RefPtr.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* port librewolf hide-default-browser to Firefox 156

The isDefaultPane/isNotDefaultPane visibility conditions gained policy and
lock checks; both are still forced false.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* port media-codec-spoofing to Firefox 156

MatroskaDecoder now asks PDMFactorySupport::IsSupported instead of a
PDMFactory instance; the media:spoof_codecs bypass sits before that loop as
before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* port network-patches to Firefox 156

nsHttpHandler.cpp's includes were re-sorted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* port screen-spoofing to Firefox 156

nsDeviceContext.cpp's includes were re-sorted, and ScreenDimensionManager.cpp
includes nsPIDOMWindowInlines.h, which nsPIDOMWindow.h no longer pulls in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* port sessionstore-popup-close-crash to Firefox 156

SessionStore moved to a class with private #windows, and
maybeDontRestoreTabs is now a method; the untracked-window guard is the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* port trusted-automation-events to Firefox 156

HTMLSelectElement::HandleMouseDown now opens the picker through
TogglePickerInternal, which also handles the in-content base-select popover
(where the release stays in content and pointerup does fire).
TogglePickerInternal now reports whether it opened the parent-process
dropdown, and only then is the following pointerup suppressed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* include nsPIDOMWindowInlines.h in the per-context managers

Firefox 156 stopped including nsPIDOMWindowInlines.h from nsPIDOMWindow.h, so
the managers' IsFunctionEnabledForWebIDL checks, which call
nsPIDOMWindowInner::GetBrowsingContext(), no longer link. Each manager now
includes it itself; the rest of these patches applied unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* port webrtc-ip-spoofing to Firefox 156

- The getStats() resolve lambda changed (MakeRefPtr report); the IP
  sanitisation is rebased onto it.
- RTCIceCandidateStats.transportId is now a required member, and Firefox 156
  exposes foundation and usernameFragment on candidate stats to content. The
  fabricated srflx entry now carries the same foundation (1) and ufrag as its
  SDP line, so it is not the only local candidate without them.
- WebRTCIPManager.cpp includes nsPIDOMWindowInlines.h (see the managers).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* port windows-theming-bug-modified to Firefox 156

Firefox 156 removed the macOS repackage recipe from browser/app/Makefile.in;
the bundle is declared with MacOSBundle() in browser/app/moz.build and every
extra file becomes a make prerequisite. The optional Assets.car copy is ported
to where those prerequisites are generated, still for that one file only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* package.py: name Firefox 156's gfxtest probe

Firefox 156 replaced the glxtest and vaapitest helpers with a single gfxtest
program. The packaging list never named any of them, so gfxtest ships; the
comment explaining why now names the probe that exists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* playwright: announce form-submit navigations on Firefox 156

Firefox 153 made form submission asynchronous (dom.forms.submit_async_navigation,
on by default): nsDocShell::OnFormSubmit plans the navigation instead of
going through OnLinkClick, so Juggler never heard juggler-link-click for a
form and click() returned before the submit's request existed. Upstream
Playwright notifies from OnFormSubmit for the same reason; this ports that
hunk. Caught by test_should_parse_the_data_if_content_type_is_form_urlencoded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* gfx-probes guard: check for Firefox 156's gfxtest probe

Firefox 156 folded the glxtest and vaapitest helper programs into a single
gfxtest binary that runs both probes as modes. The guard still requires the
probe to ship beside the binary, still requires the packaging list not to
drop it, and still requires a bare launch to get a WebGL context; only the
file name it looks for follows the rename.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Gate-Change: Firefox 156 replaced the glxtest and vaapitest programs with one gfxtest (toolkit/xre/gfxtest/moz.build Program("gfxtest"), widget/gtk/GfxInfo.cpp GFX_PROBE_BINARY u"gfxtest"); the official 156.0.1 linux-x86_64 tarball ships gfxtest and neither old name.

* juggler: let grantPermissions cover Firefox 156's local network access

Firefox 156 enables Local Network Access blocking in release builds
(network.lna.blocking), gated by the new local-network and loopback-network
permissions. Playwright 1.62 maps its local-network-access permission to
those two names, but Juggler only applies what is listed in ALL_PERMISSIONS,
so the grant was silently dropped and a public page reaching localhost stayed
parked on a prompt no one can answer. Upstream Juggler lists them too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* run_sundial: exempt sundial's origin from Firefox 156's local network access

Sundial posts its score from its public origin to the harness's collector on
127.0.0.1. Firefox 156 turns Local Network Access blocking on in release
builds, so that POST now waits for a permission prompt no one can answer and
the job times out with nothing measured (CI round 1). The browser is right to
block it -- stock 156 does the same -- so the harness exempts only sundial's
host with Firefox's own network.lna.skip-domains pref. That leaves the
Permissions API reading the stock 'prompt' for the page, unlike a
local-network-access grant, which would also mark geolocation and
notifications denied for the origin. Nothing the job measures or asserts
changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Gate-Change: Firefox 156 made network.lna.blocking true in release builds (modules/libpref/init/StaticPrefList.yaml; @IS_NIGHTLY_BUILD@ in 152.0.4), so a public page's POST to the 127.0.0.1 collector is held for an LNA prompt; reproduced locally (156: collector gets nothing, 152.0.4: delivered; with skip-domains for the page's host: delivered, permission state still 'prompt').

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 20:14:47 +00:00
Jake WriterandClaude Opus 5.5 8823d399b8 fix(fpgen): keep a values.dat the TypeScript launcher decompressed from the pin
CAMOUFOX_FPGEN_DATA may point the TS launcher at pythonlib's fpgen data/,
and it decompresses values.dat there. ensure_fpgen_model() refused to run
whenever values.dat existed, so sharing the directory broke every Python
launch. The pin now carries values.dat's sha256 (all three twins): a
matching values.dat is kept, any other is removed, since fpgen reads it in
preference to the verified archive. The files `fpgen decompress` leaves
still fail loudly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 20:09:03 +00:00
coffeegrind123 7f0e52e792 Install the pinned fpgen model before fpgen is imported
pythonlib left the model to fpgen, whose first import downloads the
first release the GitHub API lists: model-4/2025, whose WebGL records
have no vendor or renderer. Every generated launch on a fresh install
failed with KeyError: 'vendor'. scripts/pin-fpgen-model.py pinned the
model for CI only, and fpgen's five-week refresh replaced even that
pin, under a stamp the script's --check still trusted.

fpgen is now imported only through fpgen_model.load_fpgen(), which
installs the release named by the pin, checks the archive and each
file against their sha256, and dates the files past fpgen's refresh.
It writes the layout and stamp the script and the TypeScript launcher
use, verifies an install by hashing it, and leaves FPGEN_MODEL_URL to
fpgen. `camoufox fetch` installs the model as well. The pin gains each
file's sha256; the package carries a copy of it, and the script now
calls the module.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
(cherry picked from commit f043de3598)
2026-09-27 20:09:03 +00:00
Jake WriterandClaude Opus 5.5 0c6cc0a397 Official TypeScript/JavaScript launcher at parity with pythonlib, published to npm (#785)
* feat(ts): import the TypeScript launcher port from feat/captchakrakenAndJSSupport

CAPTCHA support is left out; this branch is the JS/TS driver only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(ts): drop the CAPTCHA wiring left behind by the import

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(ts): port fpgen to TypeScript, on the same pinned model

fpgen is not on npm. The port reads scripts/data/fpgen-model.json and checks
its sha256 with TLS on, never fpgen's own first-release download. Everything
that does not depend on the random draw is identical to Python (network,
value lookups, trace probabilities, conditions, errors); the draws are held to
Python's distributions by chi-square tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(ts): identity layer at parity with pythonlib

A bit-exact port of CPython's random.Random, numpy's PCG64 choice and orjson's
serialisation, so identity_salt/identity_seed and every seeded draw (fonts,
voices, media devices, WebGL, noise seeds) come out identical to Python for the
same identity. coherence.py, presets and screen/window fixes are ported, and
golden fixtures recorded from pythonlib hold all of it to exact equality.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(ts): launcher at parity with pythonlib's launch_options

launch_options() now produces pythonlib's output byte for byte (CAMOU_CONFIG,
CAMOU_PREFS_N, prefs, env, fontconfig, warnings) over 89 recorded scenarios.
Ports core pinning, geolocation, locales, fontprobe, the async API, and the
pkgman/multiversion integrity checks. An opt-in e2e suite launches a real
build through both launchers and compares what a page sees.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ci: test the TypeScript package, and publish it to npm like pypi

- ci/run_typescript.py writes the `typescript` gate (typecheck, lint, vitest
  with the pythonlib golden tests) and, with --browser, `typescript_browser`
  (the e2e suite against the browser under test). Both are required by the gate.
- publish-npm.yml mirrors publish-pypi.yml: workflow_dispatch, checks, build,
  scripts/check-pack.mjs (version == pythonlib, every data file shipped, the
  tarball installs and imports), then publish via npm trusted publishing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(ts): e2e that holds on any browser, NewContext, and the package on every PR

- Parity (TS == Python on the same binary) stays strict everywhere; whether the
  browser honours the config is asserted only on a binary whose properties.json
  knows every key the launcher sets, and otherwise skips naming the missing keys.
  A driver-only pull request is tested against the published release, which
  lags the launcher (beta.30 predates #779), so this is what makes the suite
  meaningful there instead of red on skew it cannot fix.
- New: NewContext in a real browser -- a per-context identity that differs from
  the launch identity and from a sibling context, and equals Python's.
- python_probe.py keeps stdout for its JSON (pythonlib prints "Skipping unknown
  patch" there), and a non-JSON reply now fails fast instead of hanging 240 s.
- The typescript gate builds the package and runs scripts/check-pack.mjs, so a
  packaging mistake fails the pull request that makes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ci): commit the launch fixtures, and fetch the browser from its real directory

- The root .gitignore ignores every path named `launch` (local build output),
  which silently dropped typescript/tests/fixtures/launch/ -- the
  launch_options() goldens -- from the branch. Re-included in
  typescript/.gitignore.
- fetch-browser read camoufox-bin from `camoufox path`, the cache ROOT, but
  multiversion installs each build under browsers/<channel>/<version>/, so the
  job has failed on every driver-only pull request since #772. It now resolves
  the active build as the launcher does (pkgman.camoufox_path).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ci(ts): give the typescript job pythonlib, so the cross-language checks run

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ts): fpgen model install is safe across processes

Processes installing into an empty cache at once each downloaded the model,
and one's install deleted the values.dat another had just decompressed, which
then failed its next lookup with ENOENT. Seen with vitest's parallel files on a
cold cache; a worker pool on a fresh machine would hit it too.

- ensureModel() installs under a cross-process lock (an atomic mkdir, stale
  after 10 min) and re-checks what is installed once it holds it.
- values.dat is only removed when the model is actually being replaced.
- The model keeps values.dat open, instead of reopening it on every lookup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: make local runs and CI see the same test suite

Three ways the suite passed here and not on the runner, each fixed at its cause:

- The root .gitignore's bare `launch` rule (for the Go launcher binary) ignored
  every path segment named launch, so tests/fixtures/launch/ never reached git.
  Anchored to /launch; and ci/run_typescript.py now fails when any file under
  typescript/{src,tests,scripts} is git-ignored, which would have caught it on
  the machine that wrote the fixtures.
- A missing prerequisite (fpgen model, pythonlib venv, fontTools, Xvfb, a font
  directory) skipped its tests, and a skip reads as green. tests/prereq.ts now
  fails them under CI unless the job names the gap in
  CAMOUFOX_TEST_ALLOW_MISSING. The typescript job installs all of them. The
  font-name check read one developer's local browser bundle; it now reads
  /usr/share/fonts (or CAMOUFOX_TEST_FONT_DIR), and CI installs a .ttc set.
- The fpgen install race surfaced only on a cold cache, by accident. It now has
  deterministic tests: a same-model reinstall keeps values.dat (verified to fail
  on the old code), the lock admits one holder and releases on error, and a
  stale lock is reclaimed.

Also: the browser gate runs only the e2e file, and the e2e probe and the
virtual-display test time-box each await, so a hang names its step instead of
reporting a bare 240 s timeout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(ts): hold headless="virtual" to Python's, not to headless

On the runner (no media hardware), published beta.31 never settles
enumerateDevices() in a headful window while headless answers -- the named
timeout in the probe caught it. That is a browser property, so like the other
page-vs-config checks it moves to a test that runs on a binary current with
the launcher; the virtual-display test now requires the same page as Python's
headless="virtual" on the same binary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(build-tester): accept 18 and 22 cores, as real hardware reports

plausibleHWC's list of common core counts lacked 18 and 22 -- Intel Meteor
Lake laptops (Core Ultra 5 125H, Core Ultra 7 155H), and 22 is in 8 recorded
presets. build-tester draws random presets, so a run that picked one of the two
Linux presets reporting 22 failed: about one run in eleven, on any pull
request. A CI self-test now fails if the list rejects any core count pythonlib
can present (the presets and PLAUSIBLE_CORE_COUNTS).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ci): test on the published release only when it matches this tree

A pull request that did not touch the browser was always tested against the
published release. The patch guards and suites come from the checkout, so once
a browser change was merged but not yet released (#779, on top of beta.31),
every driver-only pull request ran #779's guards against a browser without
#779 -- eight guards failed on #785, which changes no browser source.

resolve now also compares the tree's browser sources with the tag the release
was cut from (v<version>-<release> from upstream.sh), and builds when they
differ or the tag does not exist. Building restores the base branch's cached
browser when its compiled half matches -- main's #779 build, here -- so the
extra cost is a cache restore, not a compile. Self-tests run the workflow's own
scope step in a scratch repo for the four cases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(ts): say when CI's browser cannot present the configured locale

With #785 finally tested on a browser current with the tree (main's cached
#779 build), every TS-vs-Python parity check passed and the page-vs-config
check failed: a de-DE/fr-FR identity presented en-US. Python presents the same
on that binary. CI tests the build job's unpackaged dist/bin, whose
res/multilocale.txt lists en-US only -- scripts/package.py injects the
langpacks, and CI never packages. So no CI suite had ever run a non-English
locale on a browser that has one.

The e2e locale assertions now run when the binary under test packages the
configured locale (read from res/multilocale.txt, loose or in omni.ja), and
otherwise go through prerequisite("packaged-locales"), which fails in CI
unless the job names the gap. The typescript (browser) job names it, with the
reason; the rest of the page-vs-config check stays strict. On a packaged #779
build all of it, locale included, passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(guards): judge the query-cost probes on a median, not one sample

stock-parity-probes timed each getter once. On a shared runner one GC pause or
CPU-steal spike decided the verdict: navigator.hardwareConcurrency took 77 ms
against a 50 ms allowance on the same restored build that passed the run
before. Each pair is now timed five times, interleaved, and compared by median.
The regressions these catch (a sync IPC per read, ~240 ms over the loop) cost
extra on every read, so they move the median; verified by giving the getter a
constant ~4 us of extra work per read -- 86 ms median, FAIL -- while the
healthy build passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(ts): give the headful e2e page focus before probing it

enumerateDevices() intermittently never settled in the headless="virtual"
test on CI (passed one run, timed out the next, same build). Firefox defers
device enumeration until the document has focus -- LEAKS row 57 recorded the
same for a background tab -- and headless mode fakes focus while a headful
window on a bare Xvfb, with no window manager, only sometimes receives it. A
user's window has focus, so both launchers' virtual-display probes now bring
the page to the front first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: remove build tooling nothing uses

- The developer UI (scripts/developer.py, `make edits`). It depended on
  easygui, which no requirements file declares, and every action it offered is
  a Makefile target: patch, unpatch, workspace, revert, diff. Its two helpers in
  scripts/_mixin.py (is_bootstrap_patch, patch) had no other callers.
- legacy/, the Go launcher deprecated in 2024-11. Nothing built or shipped it.
  Its Makefile targets and scripts/run-pw.py go with it, and so does Go from
  every dependency list and workflow.
- jsonvv/ and settings/camoucfg.jvv. Nothing read the .jvv schema: config is
  validated against settings/properties.json, and the two had already drifted.
  The jsonvv package stays on PyPI.
- Scripts with no caller: bootstrap.py, moztree, setup-wasi-linux.sh,
  package-helper.sh, install-local-build.sh, mozfetch.sh (copied into lw/ but
  never packaged), examples/.
- The pre-ESM Juggler copies JugglerFrameParent.jsm and JugglerFrameChild.jsm,
  and hidden-scrollbars.css. Juggler loads the .sys.mjs actors and deliberately
  no stylesheet, but jar.mn still packaged all three.
- patches/librewolf/*.opt, which list_patches() never picks up; the roverfox
  second pass in patch.py, whose directory no longer exists; the unread
  --no-settings-pane option.
- The CAMOUFOX_PASSWD secret passed to `make fetch` and closedsrc_rev in
  upstream.sh, which nothing reads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(python): remove dead helpers and a stale dependency

None of these had a caller:
- pkgman: is_supported_path, extract_zip, cleanup and set_version, left over
  from the single-directory install. cleanup() would have deleted every
  installed browser version.
- multiversion.get_cached_repo_names, CONSTRAINTS.as_range,
  fingerprints._load_os_voices, utils._clean_locals, and unused imports.

Also:
- The "Apify Fingerprints" row in `camoufox version`, which has read "?"
  since fpgen replaced BrowserForge.
- lxml is no longer a dependency; nothing imports it.
- The geoip extra now names maxminddb, the module geolocation.py actually
  imports, rather than getting it transitively through geoip2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: show the cursor paths humanize=True actually produces

The README's cursor video showed the Bezier generator Camoufox replaced
with Cursory's recorded trajectories. scripts/cursor-demo.py drives a real
build with humanize=True and records every mousemove event the page
receives. It writes assets/humanize-cursor.svg, an animated replay at the
recorded speed, so what the figure shows is what a site sees.

The script cannot change the binary, so ci/browser_inputs.py lists it as
non-native and editing it does not invalidate the cached browser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(python): stop naming BrowserForge in user-facing text

fpgen replaced BrowserForge, but two LeakWarnings, the NonFirefoxFingerprint
message and the fingerprint_preset docstring still named it. One warning also
linked to a README anchor that no longer exists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: one AGENTS.md for every agent, a roadmap, and docs that match the code

- AGENTS.md holds the engineering rules for any coding agent, plus the
  repo map, build, patch and test commands that CLAUDE.md used to carry.
  CLAUDE.md now only imports it, so there is one set of rules.
  ci/tribal-rules.yml is the record of settled decisions it points to.
- ROADMAP.md lists planned work, each item linked to its issue.
- README:
  - fpgen and the coherence check replace BrowserForge;
  - the patch workflow uses the make targets instead of the removed
    developer UI;
  - letter-spacing noise is described as off by default, as it is.
- docs/:
  - beta-testing-ff146.md removed;
  - patch-upgrading-guide rewritten around the make targets;
  - per-context-patches without the canvas patch that no longer exists,
    and with measured preset counts;
  - playwright-maintenance without the JSM wrapper that does not exist;
  - smaller fixes in MEDIA-DEVICES, input-dispatch and FONTS.
- ci/README: every job, and the real shard, skiplist and entry-point lists.
- pythonlib, tester and patch-dependency READMEs corrected against the code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(pythonlib): handle headless='virtual' in launch_server

launch_server() is documented to take the same arguments as Camoufox(),
but passed headless='virtual' straight to launch_options(), so the server
launched with no Xvfb display. Start a VirtualDisplay the way Camoufox()
does, launch headful on it, and kill it when the server process exits or
the launch fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(python): remove fontprobe, which nothing called

fontprobe listed the fonts installed on the host, for a `camoufox fonts`
command that was never added. It has nothing to do with the font bundle
Camoufox serves to pages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(license): the Python launcher is MIT; the browser stays MPL-2.0

The Python package has always been published to PyPI as MIT (#727), but
pythonlib/ shipped no licence file, and the repo's LICENSE is the browser's
MPL-2.0. MPL is copyleft per file. It covers the modified Firefox sources, not
a separate launcher that drives the browser over Playwright. So
pythonlib/LICENSE now carries the MIT text its metadata already declares, and
a Licensing section in the README says which part is which.

Closes #727.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(python): fingerprint_preset=False no longer turns presets on

launch_options checked `fingerprint_preset is not None`, so passing False
drew a random bundled preset, the opposite of what was asked. It now uses a
truthiness check, and a test proves that None and False never draw a preset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ci: bring the release workflow in line with the tests build job

build.yml had drifted from tests.yml. It ran actions at v1/v2 on a
retired Node runtime, prepared the source tree with bare make calls
that fail the whole release on one dropped connection, and built with
a different Python than every pull request is tested with.

- Pin every action by commit SHA, at the major versions tests.yml uses
  (checkout v4, setup-python v5, upload/download-artifact v4, the same
  remove-unwanted-software SHA), and action-gh-release v2. The release
  job holds contents: write, so it should not follow a movable tag.
- Prepare the tree with `python3 -m ci.run_prepare`, as the tests build
  job does. BUILD_TARGET is set from the matrix so `make dir` writes the
  right mozconfig and Rust targets; multibuild.py then finds _READY and
  builds without re-patching. mach's toolchain bootstrap ignores the
  mozconfig, so running it after `dir` bootstraps the same toolchains.
- Build with Python 3.12, the version the tests build job compiles with.
- Default the workflow to no permissions; the build job gets
  contents: read and the release job keeps contents: write.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(python): NewContext looks up a proxy's exit IP through the right URL, or fails

NewContext derives the context's WebRTC IP and timezone from the proxy's exit
IP. That lookup had two defects, and both left the context showing the
host's values while its traffic went through the proxy:

- It built its own proxy URL with urlparse, which reads a scheme-less server
  such as "1.2.3.4:8080" (a form Playwright accepts) as scheme "1.2.3.4" with
  no host. urllib could not use a SOCKS proxy at all.
- Any failure was swallowed, and the context opened without the values.

The URL is now built with Proxy.as_string(), which the geoip launch path
already uses (scheme-less means http). The lookup goes through requests,
which handles SOCKS, and a failed lookup raises InvalidIP, naming the two
options that skip it. The tests cover scheme-less, http and socks5 servers
with credentials, both failure modes, and the case where no lookup is needed,
for NewContext and AsyncNewContext.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: stop generating a canvas seed, and drop config keys nothing reads

The browser has not noised the canvas since #528, and no patch reads
canvas:seed (#721). The launcher still drew one on every launch and sent it
through CAMOU_CONFIG, and NewContext called a setCanvasSeed that does not
exist. They no longer do.

For users this changes nothing on any browser since #528: the value was
ignored. A config that still passes canvas:seed gets the usual "Skipping
unknown patch" notice instead of silence. On a browser from before #528, the
launcher no longer turns canvas noise on, which is the behaviour #528 chose.

The same audit found more keys declared in settings/properties.json that no
patch or Juggler file reads, so setting them did nothing:
- canvas:aaOffset, canvas:aaCapOffset
- memorysaver, pdfViewerEnabled, webrtc:localipv4/6
- navigator.onLine, navigator.cookieEnabled, navigator.languages
- navigator.appCodeName, appName, product, productSub. Firefox reports these
  constants itself, so fpgen.yml no longer maps them.
- webGl:parameters:blockIfNotDefined and its WebGL2 twin

test_config_schema now checks this direction too: every declared key must be
read by the browser, unless it is listed with a reason. Three are listed:
locale:script and navigator.doNotTrack, which the launcher applies itself,
and navigator.buildID (#780).

The build-tester grading followed the same wrong premise. It tracked canvas
collisions as an unfixed per-context leak. A canvas that is rendered rather
than noised follows the fonts and GPU, as it does on real machines, so canvas
collisions are now counted with the other device-level values. The tribal rule
that recorded it as an open question is now a settled one,
canvas-is-not-noised, with an automated check.

Closes #721.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(ts): remove dead helpers

None of these had a caller:
- pkgman: isSupportedPath, extractZip, cleanup and setVersion, left over
  from the single-directory install. cleanup() would have deleted every
  installed browser version.
- multiversion getCachedRepoNames and getCachedVersions, CONSTRAINTS.asRange,
  removeMmdb (Python keeps its twins for the GUI) and pycompat pySorted.
- The "Apify Fingerprints" row in `camoufox version`, which read "?".

utils.ts now calls noiseSeedsFromIdentity instead of repeating its two
formulas inline, so the tested function is the one that runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(ts): remove fontprobe, which nothing called

fontprobe.ts listed the fonts installed on the host, for a `camoufox fonts`
command neither launcher has. It has nothing to do with the font bundle
Camoufox serves to pages. Its parity test goes with it, and so do the CI
prerequisites only that test needed: fonttools and the extra font packages.
(The Python twin is removed in #787.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(ts): license the launcher MIT, with third-party notices

The TypeScript launcher is a port of pythonlib, which has always been
published to PyPI as MIT (#727); the MPL-2.0 of the browser covers the
modified Firefox sources, not a launcher that drives it over Playwright.

THIRD_PARTY_NOTICES.md ships in the npm package with the notices for the
code the port translates: fpgen (Apache-2.0), CPython's random (the MT19937
BSD notice and the PSF licence), and NumPy's SeedSequence and PCG64 (BSD-3
and MIT).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: document the TypeScript package outside typescript/

The README, CONTRIBUTING, ci/README and the issue templates did not mention
the npm package or its two CI gates. ci/README also still said driver-only
pull requests never build. Since the scope step started comparing browser
sources against the release tag, they build whenever the release is behind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(python): repair devicePixelRatio the same way on every launch

The DPR repair snaps an off-grid ratio to the nearest real scaling step and
keeps the first of two equally near steps. The steps were frozenset
literals, and a frozenset literal iterates in one order when the module is
compiled from source and another when it is loaded back from a .pyc. So a
midpoint such as 1.125 became 1.25 on the first launch after an install and
1 on every launch after it: the same pinned identity presented two different
devicePixelRatio values.

The steps are now ascending tuples, so a tie always goes to the lower step.
The test runs the repair in two fresh interpreters that share a bytecode
cache, compiling in the first and loading in the second. It failed before
this change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ts): mirror #787's pythonlib fixes

The TypeScript side of the behaviour #787 changes in pythonlib, so the port
stays at parity:
- fingerprint_preset=false no longer draws a preset.
- NewContext builds the proxy URL with ProxyHelper.asString() (scheme-less
  means http), looks up the exit IP through impit, and throws InvalidIP when
  the lookup fails instead of opening the context with the host's values.
- No canvas seed is generated or sent (#721). noiseSeedsFromIdentity becomes
  audioSeedFromIdentity, and fpgen's constant navigator fields are no longer
  mapped.
- The devicePixelRatio steps are ascending, so a tie goes to the lower step.
- The two LeakWarning texts that named BrowserForge.
- The README's note that Python's launch_server() ignored headless='virtual'
  is gone, because it no longer does.

The golden fixtures are regenerated from #787's pythonlib. The generator now
masks the fontconfig file name the way the test already did. The name hashes
content that embeds the checkout path, so every regeneration from a different
checkout used to rewrite 76 fixtures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: remove the glyph-spacing seed from the browser and the launcher

anti-font-fingerprinting.patch added a seeded amount to every glyph advance,
so that text widths differed per context. No real machine produces those
widths: the same font on the same OS measures the same everywhere. So the
noise was itself a fingerprint, measured in #779 at +1 px per ~100 glyphs
plus fractional deltas on every measureText. #779 defaulted the seed to 0
and kept it as an opt-in, but an opt-in whose only effect is to become
detectable is not worth carrying. Removed:

- The browser side:
  - FontSpacingSeedManager and window.setFontSpacingSeed;
  - the HarfBuzz hook;
  - the plumbing that existed only to carry the context id down to the
    shaper: the userContextId on gfxTextRun, gfxShapedWord and the word-cache
    key, and the extra MakeTextRun argument in nsTextFrame, nsFontMetrics,
    MathML and canvas.
  The font group keeps its userContextId, which font-list-spoofing.patch
  uses to apply the per-context font list. Text is now shaped exactly as
  stock Firefox shapes it.
- The fonts:spacing_seed key. The launcher had been sending 0 on every
  launch, plus a setFontSpacingSeed(0) call in every context's init script.
- tests/patches/config-overrides.py, which tested only the spacing override.
  A pythonlib test now covers config_overrides with another key.

timezone-spoofing, webrtc-ip-spoofing and window-setter-seal change only in
context lines and the setter seal list. Every patch applies cleanly to a
fresh tree, and the result builds. The settled decision is recorded as
no-glyph-spacing-noise, with an automated check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: stock animations and speech by default; drop config keys that freeze live values

Three behaviours a page could detect, changed in one breaking release:

- **Animations run on stock timing.** no-css-animations.patch finished every
  finite animation at once by default, and any page could read it:
  `el.animate(frames, 1000).effect.getComputedTiming().duration` was 0, and a
  500ms transition reported 0. Measured on v152.0.4-beta.31. The speedup is
  now an opt-in, `instantAnimations: True`, which raises a LeakWarning.
  disableInstantAnimations is gone.
- **speak() on a spoofed voice works like a real voice.** It fired `error`
  after 3ms unless voices:fakeCompletion was set, and then start and end in
  the same tick. It now starts and ends after the text's duration at ~150
  words per minute. Both voices:fakeCompletion keys are gone, and so is a
  debug line printed to stderr on every call.
- **Keys removed:**
  - battery:* and window.scrollMinX/Y: Firefox keeps getBattery() and
    scrollMin* chrome-only, so no page could read them.
  - window.scrollMaxX/Y, screen.pageXOffset/pageYOffset,
    window.history.length and document.body.client*: each pinned a live value
    to a constant, so scrolling, navigating or re-laying out never changed it.
    fpgen.yml mapped pageYOffset, so about 15% of identities froze
    window.scrollY at a non-zero value.
  - The body keys' role as an undocumented alias for window.innerWidth/Height
    in browser-init and in the launcher.
  - MaskConfig::GetInt32Rect, which only the body keys used.

New guards, both of which fail on v152.0.4-beta.31:
tests/patches/animation-timing.py and tests/patches/spoofed-voice-speaks.py.
The decisions are recorded as animations-run-on-stock-timing and
spoofed-voices-speak. Every patch applies cleanly to a fresh tree, and the
result builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(python)!: remove dead public API and make `list all --path` work

Breaking changes:

- Remove the exceptions UnknownProperty, InvalidDebugPort and
  MissingDebugPort. Nothing in the package raises them, so code catching
  them was catching nothing.
- Remove the legacy `allow_webgl` keyword of launch_options(). Use
  `block_webgl=True`. The keyword now reaches Playwright as an unknown
  launch option and fails there instead of being silently consumed.

`camoufox list all --path` accepted the flag and ignored it. It now prints
the install path beside each installed build, as `camoufox list --path`
already does for the installed tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(python)!: drop data the package never draws from

voices.json shipped in the wheel, but no code in the package reads it: the
voice draw uses voice-manifests.json and voice-uris.json. Its only readers
are the TypeScript port's golden-fixture generator and data-sync script
(typescript/scripts/golden/identity_golden.py,
typescript/scripts/sync-identity-data.py), which live on another branch and
will need a new source; the last copy is at
676fb3f:pythonlib/camoufox/voices.json. docs/per-context-patches.md
described it as runtime data and now describes the files that are.

webgl_data.db held two rows with zero weight on every OS ("Intel(R) HD
Graphics 400, or similar" from "Intel Inc." and "Radeon R9 200 Series, or
similar" from "ATI Technologies Inc."), left behind when their impossible
macOS weights were zeroed. No draw can reach them. They are deleted with
secure_delete so their blobs do not linger in free pages; the file is not
vacuumed, so the other pages are unchanged. A new test requires every row
to be drawable on at least one OS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(python): warn whenever an identity falls back to a substitute value

Several draws swallowed their failure and used something else, so an
identity could ship with values the rest of it was not drawn to match and
nobody would hear about it:

- from_preset(): a failed font or voice draw used the preset's recorded
  list, or nothing, on any exception.
- generate_context_fingerprint(): a failed font, voice or WebGL draw was
  `except Exception: pass`, leaving the browser's launch-time values.
- _load_font_groups() / _load_font_bases(): an unreadable file became {},
  i.e. no font additions or no OS-version base.
- launch_options(): a failed font draw used every font in fonts.json, a
  failed voice draw used no voices, and a preset GPU missing from
  webgl_data.db was silently swapped for a drawn one (36 of the 397
  bundled presets).

Each site now catches only the errors its data can raise (OSError and
ValueError for an unreadable or corrupt file, KeyError for a manifest with
no entry for the OS, sqlite3.Error for the WebGL database) and emits a
FallbackWarning. The text names what failed and what the identity uses
instead, then gives a block to paste into an issue (camoufox, browser, OS
and Python versions, the error, and the identity's user agent or GPU),
asking the user to report it on GitHub. It shares LeakWarning's
caller-frame attribution and its template lives in warnings.yml.

The broad excepts had also been hiding a broken fixture:
test_launch_environment's font and voice stubs did not accept `seed`, so
every draw there raised and was swallowed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(python): give NewContext identities the browser's Firefox version

NewContext() and AsyncNewContext() passed ff_version=None through to
generate_context_fingerprint(), so a context's user agent kept the version
fpgen drew (e.g. Firefox/146) while the browser underneath was 152. They
now default ff_version to the major version of Playwright's
Browser.version, which Juggler reports from MOZ_APP_VERSION_DISPLAY, so the
UA always names the browser the page is actually talking to. An explicit
ff_version still wins.

The docstrings said each context gets "its own real fingerprint preset";
the default has been an fpgen draw, with a preset only when one is passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(python): send an IPv6 WebRTC address to setWebRTCIPv6

The per-context init script passed every webrtc_ip, IPv6 included, to
window.setWebRTCIPv4(), and never called setWebRTCIPv6(). An IPv6 address
(given directly, or resolved as a proxy's exit IP) was stored as the
context's IPv4 value and the IPv6 slot stayed empty. The script now picks
the setter by address family, and an address that is neither raises
InvalidIP instead of being passed through.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(python): stop pinning the page's scroll offset from fpgen

fpgen.yml mapped the drawn window.pageYOffset (e.g. 528) to
screen.pageYOffset, and the browser returns that value from scrollY on
every read, so a page saw one scroll position forever whatever the user
did. Real scroll offsets are live page state, not part of a device's
fingerprint, so neither offset is mapped any more.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(python): stop checking a config key that no longer exists

warn_manual_config() looked for navigator.languages, which was removed
from settings/properties.json; validate_config() rejects it before the
check could matter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(python): close the WebGL database connection on every path

sample_webgl raised its not-found and wrong-OS errors before reaching
conn.close(), leaking a sqlite connection each time a preset named a GPU the
database does not hold.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(data): drop the 23 presets whose GPU has no WebGL data

A preset records only its GPU's name. The WebGL parameters, extensions and
shader precision behind it have to come from somewhere, and for these 23
nothing Camoufox has describes the GPU: fpgen has never seen Firefox report it
on that OS. So each launch paired the name with another device's parameters,
a mismatch any WebGL fingerprinter can see. They were:

- Windows on ARM (Adreno 650);
- Direct3D 10-level GPUs (vs_4_0/vs_4_1);
- "Generic Renderer";
- 945GM and GTX 480 on macOS;
- nouveau/Mesa buckets on Linux;
- one Linux preset pairing NVIDIA's proprietary vendor string with the
  nouveau renderer name.

scripts/clean-fingerprint-data.py now applies the rule, via a shared
fingerprints.firefox_gpus(), and test_shipped_data asserts it. 374 presets
remain, and every OS keeps its presets. ROADMAP.md lists capturing WebGL data
for these GPUs, which would bring them back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ts): stop generating the glyph-spacing seed

Mirrors 676fb3f: the browser no longer has glyph-spacing noise, so the
launcher sends no fonts:spacing_seed and the per-context init script no
longer calls setFontSpacingSeed. config_overrides is now tested with
audio:seed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ts): warn on instantAnimations; stop treating body keys as window size

Mirrors the launcher half of fb21b2e: instantAnimations raises the
instant_animations LeakWarning (warnings.yml copied from pythonlib), the
document.body.client* keys no longer count as window dimensions, and fpgen's
pageYOffset is no longer mapped, so no identity freezes window.scrollY.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ts)!: remove dead public API and make `list all --path` work

Mirrors 4616aa5: drop the UnknownProperty, InvalidDebugPort and
MissingDebugPort exceptions (nothing raises them) and the legacy allow_webgl
option (use block_webgl; allow_webgl now passes through to Playwright like
any unknown option). `camoufox list all --path` prints each installed
build's path, as `list --path` already did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(ts)!: drop voices.json, which nothing draws from

Mirrors 9a42b6a. The voice draw reads voice-manifests.json and
voice-uris.json; voices.json was only read by the golden generator and the
data-sync script. The voice-URI golden now hashes the URI of every entry in
voice-manifests.json, the list the draw actually picks from.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ts): warn whenever an identity falls back to a substitute value

Mirrors 165e68f for the font and voice draws. Each fallback site in
fromPreset(), generateContextFingerprint(), loadFontGroups(),
loadFontBases() and launchOptions() now catches only the errors its data can
raise and emits a FallbackWarning naming what failed, what the identity uses
instead, and a block to paste into an issue (camoufox, browser, OS and Node
versions, the error, the identity). The message is warnings.yml's
`fallback` template, shared with pythonlib.

Python's except clauses name builtin classes JavaScript lacks, so pycompat
gains OSError, ValueError and KeyError twins and isPyError(): a Node system
error counts as an OSError and JSON.parse's SyntaxError as a ValueError, as
json.JSONDecodeError is. The voice draw now throws ValueError for a malformed
entry and KeyError when the manifest has no macOS entry, as Python does.

The WebGL fallback sites are left for the change that replaces the TS WebGL
source.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ts): give NewContext identities the browser's Firefox version

Mirrors 46e5c8d: without an explicit ff_version, NewContext() kept the
Firefox version fpgen drew, so a context's UA could name 146 on a 152
browser. It now defaults to the major version of Browser.version(). The
option docs now say the default identity is an fpgen draw.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ts): send an IPv6 WebRTC address to setWebRTCIPv6

Mirrors 0bbd152: the per-context init script passed every WebRTC IP to
setWebRTCIPv4(), IPv6 included. It now picks the setter by address family
and raises InvalidIP for an address that is neither. The init-script golden
gains an IPv6 case.

Also ports 7b43112's regression test: a drawn pageXOffset/pageYOffset is
not carried into the config (the mapping went in fb21b2e's mirror).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(ts): stop checking a config key that no longer exists

Mirrors 480789a: warnManualConfig() looked for navigator.languages, which
settings/properties.json no longer has; validateConfig() rejects it first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(ts): sync the pruned presets and the properties.json fixture

Copies fingerprint-presets*.json from pythonlib (40edebb dropped the 23
presets whose GPU has no WebGL data) and refreshes the launch fixture's copy
of settings/properties.json, which lost the keys removed in 676fb3f and
fb21b2e.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(python): draw every identity's WebGL from fpgen

WebGL vendor, renderer, context attributes, extensions, parameters and
shader precisions, for WebGL1 and WebGL2, now come from fpgen's recorded
Firefox devices instead of webgl_data.db, which is deleted with the
camoufox/webgl/ package.

camoufox/webgl.py:
- webgl_for_gpu() traces `webgl` given Firefox, the OS and the GPU, then
  `webgl2` given the chosen `webgl` too, and draws each with one seeded
  random.Random. The GPU and the webgl value are pinned by their fpgen
  lookup index: a dict condition is flattened into leaves that overwrite
  each other, so only the renderer applied and Linux "Mesa" and "AMD"
  Radeon HD 3200 devices came back mixed.
- sample_webgl_for_screen() draws the GPU of a generated identity from
  fpgen's per-OS weights, filtering out software rasterisers, GPUs the OS
  cannot report, discrete GPUs behind a netbook screen and the
  resistFingerprinting "Mozilla" mask before the weighted choice, so there
  is no rejection loop. An empty pool raises.
- The draft/host-dependent extension filter moves over unchanged.

A preset's GPU and a caller's webgl_config pair are looked up as given;
a pair fpgen has never seen from Firefox on that OS raises instead of
falling back to another GPU. generate_context_fingerprint no longer
falls back to the host GPU when the draw fails.

For 10 of the 15 (GPU, OS) pairs the two sources share, one of fpgen's
records converts to exactly the database row on every value the browser
reads. The other five rows (Linux R9 200 and Radeon HD 3200, macOS
Intel HD, and two software rasterisers) are devices fpgen does not carry;
those GPUs now present fpgen's recorded devices instead. The Linux
GTX 980 row is kept as a test fixture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: say where WebGL comes from now that the database is gone

The per-context guide, the fpgen.yml header and coherence's comments still
named webgl_data.db and sample_webgl(). They now point at camoufox/webgl.py
and fpgen. The guide also claimed presets carry WebGL parameters; they
record only the vendor and renderer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(ts): regenerate the golden fixtures for the mirrored pythonlib changes

Regenerates identity_golden.py and launch_golden.py output from this tree's
pythonlib. launch_golden.py drops fonts:spacing_seed and
document.body.clientWidth from its inputs, adds an instantAnimations
scenario, and masks a FallbackWarning's report block in both launchers, since
it names the host and the runtime.

Two launch scenarios still differ: preset_windows_unknown_gpu and
config_webgl_unknown_pair expect the FallbackWarning pythonlib now raises when
a preset's GPU is missing from the WebGL data. That site belongs to the
change replacing the TS WebGL source; the rest of each scenario matches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(ts): draw every identity's WebGL from fpgen

Mirrors 0d859b3. src/webgl.ts is the twin of camoufox/webgl.py:
webglForGpu() traces fpgen's webgl node given Firefox, the OS and the GPU,
then webgl2 given the chosen webgl too; sampleWebglForScreen() first draws
the GPU from fpgen's per-OS weights, filtered (no software rasteriser, no
resistFingerprinting mask, a GPU the OS can report, no discrete GPU behind a
netbook screen) before one weighted choice. Every draw is PyRandom.choices on
one seeded instance, in Python's order. The GPU and webgl value are pinned by
their fpgen lookup index, found from the value's stored JSON, which
TraceResult now carries: re-serialising a parsed value would spell 2**64
differently from orjson.

A preset's GPU and a caller's webgl_config are looked up as given and raise
when fpgen has never seen them, instead of falling back to another GPU;
generateContextFingerprint no longer swallows a failed draw. Removed with
the old source: webgl/sample.ts, the numpy default_rng port
(webgl/nprandom.ts), data-files/webgl_data.json and its export in
sync-identity-data.py. The NumPy notice now covers the pairwise sum in
locales.ts, the one NumPy port left.

launchOptions now throws the pycompat ValueError where Python raises
ValueError. Tests port test_webgl.py and the shipped-data check that every
preset GPU has WebGL data.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(ts): pin the fpgen WebGL draws against Python bit for bit

identity_golden.py now records camoufox.webgl's output as sha256 of the
exact orjson bytes (key order, int versus float and all): 1272 screen draws
over every OS and seven screens (60 seeds each, plus four large seeds),
webgl_for_gpu for every GPU fpgen records and every bundled preset GPU, the
unknown-GPU and unknown-OS errors, and to_config's extension filter. The
numpy golden keeps only np.sum, now checked against locales.ts. The renderer
list the coherence golden walks comes from fpgen's traces.

launch_golden.py takes its WebGL pairs from firefox_gpus(), and the
unknown-GPU preset input is a GPU nobody records. The launch goldens are
regenerated; the preset_windows_unknown_gpu and config_webgl_unknown_pair
scenarios now expect Python's ValueError. The golden test no longer maps
ValueError to Error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(patches): a host's missing speech daemon no longer errors spoofed speech

On a Linux host where speech-dispatcher cannot start, Firefox broadcasts
synth-voices-error, and SpeechSynthesis answers it by firing `error` on every
queued utterance. So a spoofed Windows voice errored about 11ms into speak()
on any host without the daemon: the CI runners, and most servers. It passed
only where the daemon runs.

While Camoufox manages the voice list, the registry no longer forwards a host
backend's error. The spoofed voices do not depend on the host's engine, and a
Windows or macOS identity never raises one. The guard now makes the daemon
unreachable itself, so it tests this case on every machine; on the previous
build it fails every time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ci: stop skipping the two click tests that stock animation timing fixed

test_wait_for_stable_position and test_timeout_waiting_for_stable_position
were skipped with humanized travel time as the reason. The real cause was
instant animations. Every finite animation finished at once, so the button
Playwright waits on to stop moving never moved, and the click landed where
upstream does not expect. With animations on stock timing both pass, and the
skiplist audit flagged them as no longer failing. The entries go, and the
counts in ci/README.md drop from 14 to 12.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(build-tester): accept 18 and 22 cores, as real hardware reports

plausibleHWC's list of common core counts lacked 18 and 22 -- Intel Meteor
Lake laptops (Core Ultra 5 125H, Core Ultra 7 155H), and 22 is in 8 recorded
presets. build-tester draws random presets, so a run that picked one of the two
Linux presets reporting 22 failed: about one run in eleven, on any pull
request. A CI self-test now fails if the list rejects any core count pythonlib
can present (the presets and PLAUSIBLE_CORE_COUNTS).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(guards): judge the query-cost probes on a median, not one sample

stock-parity-probes timed each getter once. On a shared runner one GC pause or
CPU-steal spike decided the verdict: navigator.hardwareConcurrency took 77 ms
against a 50 ms allowance on the same restored build that passed the run
before. Each pair is now timed five times, interleaved, and compared by median.
The regressions these catch (a sync IPC per read, ~240 ms over the loop) cost
extra on every read, so they move the median; verified by giving the getter a
constant ~4 us of extra work per read -- 86 ms median, FAIL -- while the
healthy build passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(native): compare the whole fingerprint when two launches must differ

test_two_browsers_get_different_fingerprints compared seven coarse values:
UA, platform, screen size, core count, timezone and language. CI pins the
timezone and language, and real machines share the rest: two draws of a
common Mac (Firefox 152, MacIntel, 2560x1440, 8 cores) matched, and the test
failed on a correct browser.

It now reads the whole fingerprint a site computes, from a script in the page:
- navigator values, screen and window geometry, device pixel ratio, timezone;
- WebGL vendor, renderer, limits and extensions;
- installed fonts, measured by width against the generic fallbacks;
- voices, media-device counts, and an OfflineAudioContext hash.
The page is served from an https URL Playwright fulfils locally, because
mediaDevices exists only in a secure context. The page computes the result
itself because the isolated world may not read audio sample data. The test
then requires the fingerprints to differ, and the audio hash to differ on its
own, since its noise is seeded per identity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Update README to remove warning, camoufox is now actively maintained

Camoufox will now be actively maintained and improved for the foreseeable future

* ci(ts): hold the golden tests to live pythonlib, not a snapshot of it

The typescript job ran the golden tests against the fixtures committed in
typescript/tests/fixtures/. A pythonlib change that typescript/ did not
mirror left those fixtures untouched, so the tests kept passing -- the
opposite of what the job's comment promised.

`ci.run_typescript --regenerate-golden` now rewrites the fixtures from the
checkout's pythonlib before vitest runs, and CI passes it. The job runs
Python 3.14 because pySum() reproduces sum() as 3.14 computes it; on 3.12
one crafted mixed int/float case differs in its last bit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ci(ts): do not redraw fpgen's stats fixture on every run

stats.json is thousands of random fpgen draws that the TS tests compare
statistically. It changes with the pinned model, not with pythonlib, and
redrawing it took eight of the typescript job's eleven minutes on a
runner. The deterministic fpgen fixtures are still regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(native): measure crash growth from a warmed-up parent

test_the_parent_stays_flat_across_content_crashes took its baseline right
after launch. The parent's first context costs it 150-200 MB with no
crash at all, so the warm-up counted as crash growth: 330-370 MB of the
400 MB allowance locally, and 469 MB on a CI runner, failing a PR that
changes nothing in the browser. The baseline now follows one clean
context; each crash still has to stay within the same allowance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(ts): read pythonlib's data files instead of copying them

typescript/src/data-files/ held byte-identical copies of eleven pythonlib
data files (23k lines), kept in step by a sync script and a test that
failed when a copy drifted. The TS launcher now reads them from
pythonlib/camoufox/ when it runs from the repo, and `pnpm build` copies
them into dist/data-files/ for the npm tarball, so what users install is
unchanged. DATA_FILES in src/paths.ts is the one list; check-pack.mjs
checks each is in the tarball.

The essential-font lists were the one large table both ports hard-coded
(~170 lines of Python, ~770 of TS). They move to
pythonlib/camoufox/essential-fonts.json, which fingerprints.py and
fingerprints.ts both read; gen-fonts-json.py --print-bases writes it and
verify-fonts.py checks it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(ts): record the golden fixtures from pythonlib on every run

The goldens were pythonlib's output committed as 14k lines of fixtures,
most of it the same input fingerprint pasted into ~80 launch scenarios,
and CI already rewrote them from pythonlib before each run. They are now
recorded by a vitest globalSetup (tests/golden-setup.ts) from the repo's
.venv or $CAMOUFOX_PYTHON, in about 6 seconds, and git-ignored. A
pythonlib change that typescript/ does not mirror fails `pnpm test`
locally as well as in CI, and ci.run_typescript no longer needs
--regenerate-golden.

Committed inputs stay: launch/inputs.json, the bundle stubs, the addon,
e2e/probe.js, and fpgen/stats.json (random draws tested statistically,
which change with the pinned model, not with pythonlib, and take minutes
to redraw). The one Python-version-sensitive case, sum() over mixed ints
and floats, skips with a named prerequisite when the goldens come from
Python < 3.14.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ci(npm): give the publish job the pythonlib its tests record goldens from

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ts): NewContext hands Playwright the identity's user agent, DPR and timezone

generateContextFingerprint already returns Playwright's JS option names;
NewContext re-cased them, and camelCase() lowercases first, so userAgent,
deviceScaleFactor and timezoneId became keys Playwright silently drops.
navigator.userAgent was still spoofed at the C++ level, so the page probe
matched Python's, but the HTTP User-Agent, the DPR and the timezone did
not. The options now pass through as generated.

NewContext also awaits ensureModel(): a browser from connect() or a
custom executable never went through launchOptions(), which fetches the
fpgen model, so an fpgen draw threw ModelNotInstalled where Python works.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ts): a failed browser download rejects instead of killing the process

The download's file stream had no 'error' listener, so a failed write
(disk full) was an uncaught 'error' event: Node exited before
installVersioned()'s catch could remove the partial install and its temp
directory, and the caller had nothing to catch. finished() now listens
from the moment the stream is created, and webdl() surfaces an errored
stream instead of writing into it.

webdl() also waits for 'drain': it ignored write()'s return value, so on
a slow disk the whole archive queued in memory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ts): concurrent launches no longer read or inherit each other's CPU pin

playwright-core spawns the browser from this process, so pin_cpu_cores
narrows this process's own mask while a launch's browser starts. Python
pins a separate driver and never sees its own mask change. Here, a
second launch started in that window:

- read the pinned mask as the host's cores (pinnedCoreCount, and the
  identity's hardwareConcurrency via availableParallelism()), and
- if it did not pin, spawned its browser without the lock, inheriting the
  first launch's pin while reporting more cores.

The host's core count is now read once, before this process first pins
itself (cpu_affinity.hostCoreCount), and unpinned launches, launchServer
included, take the pin lock once any launch in the process has pinned.
With pin_cpu_cores off (the default) nothing waits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ts): an Xvfb that fails to start throws CannotExecuteXvfb

A spawn that fails after spawn() returns (EACCES, ENOENT) is an 'error'
event on the child process, which had no listener: Node treated it as
uncaught and exited instead of get() throwing. The child now always has
a listener, readDisplayNumber() rejects with CannotExecuteXvfb on it, and
the display pipe keeps an error listener after the read settles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(scripts): gen-fonts-json refuses to write an incomplete essential-fonts.json

An OS with no bases in the manifest was skipped, and the file written
without its key; fingerprints.py and fingerprints.ts read every OS's list
at import, so `import camoufox` then failed with a KeyError. The script
now exits and keeps the existing file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(release): pythonlib and the npm package to 0.5.7

@camoufox/camoufox 0.5.6 went to npm before the review fixes above, so
they ship as 0.5.7; the two launchers are versioned in lockstep, and
main already carries pythonlib changes from #787 that 0.5.6 on PyPI
does not have.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(ts): NewContext's HTTP User-Agent must match navigator.userAgent

The e2e parity check read only navigator.userAgent, which the browser
spoofs itself, so a context that dropped Playwright's userAgent option
still matched Python. The probe server now records the request's
User-Agent header. Against the NewContext before the fix, a context
whose navigator said Windows sent the launch identity's Linux UA.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(ts): draw the NewContext option-name test's preset from the v150 bundle

getRandomPreset() without a Firefox version draws from the older bundle,
where some Windows presets carry no devicePixelRatio, so the test failed
on some draws in CI. Every v150 preset has one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(ts): wait for the headful e2e page to have focus, not one bringToFront()

On a bare Xvfb, with no window manager, a single bringToFront() before
goto() sometimes left the window unfocused, and Firefox holds
enumerateDevices() until the document has focus, so the virtual-display
probe timed out on some runs. Both launchers' probes now navigate first,
then bring the page to the front until document.hasFocus() is true, and
fail with that reason if it never is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 03:28:12 +00:00
Jake WriterandClaude Opus 5.5 180e6553cc Docs that match the code, one AGENTS.md, dead code removed, and the audit's bug fixes (#787)
* chore: remove build tooling nothing uses

- The developer UI (scripts/developer.py, `make edits`). It depended on
  easygui, which no requirements file declares, and every action it offered is
  a Makefile target: patch, unpatch, workspace, revert, diff. Its two helpers in
  scripts/_mixin.py (is_bootstrap_patch, patch) had no other callers.
- legacy/, the Go launcher deprecated in 2024-11. Nothing built or shipped it.
  Its Makefile targets and scripts/run-pw.py go with it, and so does Go from
  every dependency list and workflow.
- jsonvv/ and settings/camoucfg.jvv. Nothing read the .jvv schema: config is
  validated against settings/properties.json, and the two had already drifted.
  The jsonvv package stays on PyPI.
- Scripts with no caller: bootstrap.py, moztree, setup-wasi-linux.sh,
  package-helper.sh, install-local-build.sh, mozfetch.sh (copied into lw/ but
  never packaged), examples/.
- The pre-ESM Juggler copies JugglerFrameParent.jsm and JugglerFrameChild.jsm,
  and hidden-scrollbars.css. Juggler loads the .sys.mjs actors and deliberately
  no stylesheet, but jar.mn still packaged all three.
- patches/librewolf/*.opt, which list_patches() never picks up; the roverfox
  second pass in patch.py, whose directory no longer exists; the unread
  --no-settings-pane option.
- The CAMOUFOX_PASSWD secret passed to `make fetch` and closedsrc_rev in
  upstream.sh, which nothing reads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(python): remove dead helpers and a stale dependency

None of these had a caller:
- pkgman: is_supported_path, extract_zip, cleanup and set_version, left over
  from the single-directory install. cleanup() would have deleted every
  installed browser version.
- multiversion.get_cached_repo_names, CONSTRAINTS.as_range,
  fingerprints._load_os_voices, utils._clean_locals, and unused imports.

Also:
- The "Apify Fingerprints" row in `camoufox version`, which has read "?"
  since fpgen replaced BrowserForge.
- lxml is no longer a dependency; nothing imports it.
- The geoip extra now names maxminddb, the module geolocation.py actually
  imports, rather than getting it transitively through geoip2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: show the cursor paths humanize=True actually produces

The README's cursor video showed the Bezier generator Camoufox replaced
with Cursory's recorded trajectories. scripts/cursor-demo.py drives a real
build with humanize=True and records every mousemove event the page
receives. It writes assets/humanize-cursor.svg, an animated replay at the
recorded speed, so what the figure shows is what a site sees.

The script cannot change the binary, so ci/browser_inputs.py lists it as
non-native and editing it does not invalidate the cached browser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(python): stop naming BrowserForge in user-facing text

fpgen replaced BrowserForge, but two LeakWarnings, the NonFirefoxFingerprint
message and the fingerprint_preset docstring still named it. One warning also
linked to a README anchor that no longer exists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: one AGENTS.md for every agent, a roadmap, and docs that match the code

- AGENTS.md holds the engineering rules for any coding agent, plus the
  repo map, build, patch and test commands that CLAUDE.md used to carry.
  CLAUDE.md now only imports it, so there is one set of rules.
  ci/tribal-rules.yml is the record of settled decisions it points to.
- ROADMAP.md lists planned work, each item linked to its issue.
- README:
  - fpgen and the coherence check replace BrowserForge;
  - the patch workflow uses the make targets instead of the removed
    developer UI;
  - letter-spacing noise is described as off by default, as it is.
- docs/:
  - beta-testing-ff146.md removed;
  - patch-upgrading-guide rewritten around the make targets;
  - per-context-patches without the canvas patch that no longer exists,
    and with measured preset counts;
  - playwright-maintenance without the JSM wrapper that does not exist;
  - smaller fixes in MEDIA-DEVICES, input-dispatch and FONTS.
- ci/README: every job, and the real shard, skiplist and entry-point lists.
- pythonlib, tester and patch-dependency READMEs corrected against the code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(pythonlib): handle headless='virtual' in launch_server

launch_server() is documented to take the same arguments as Camoufox(),
but passed headless='virtual' straight to launch_options(), so the server
launched with no Xvfb display. Start a VirtualDisplay the way Camoufox()
does, launch headful on it, and kill it when the server process exits or
the launch fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(python): remove fontprobe, which nothing called

fontprobe listed the fonts installed on the host, for a `camoufox fonts`
command that was never added. It has nothing to do with the font bundle
Camoufox serves to pages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(license): the Python launcher is MIT; the browser stays MPL-2.0

The Python package has always been published to PyPI as MIT (#727), but
pythonlib/ shipped no licence file, and the repo's LICENSE is the browser's
MPL-2.0. MPL is copyleft per file. It covers the modified Firefox sources, not
a separate launcher that drives the browser over Playwright. So
pythonlib/LICENSE now carries the MIT text its metadata already declares, and
a Licensing section in the README says which part is which.

Closes #727.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(python): fingerprint_preset=False no longer turns presets on

launch_options checked `fingerprint_preset is not None`, so passing False
drew a random bundled preset, the opposite of what was asked. It now uses a
truthiness check, and a test proves that None and False never draw a preset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ci: bring the release workflow in line with the tests build job

build.yml had drifted from tests.yml. It ran actions at v1/v2 on a
retired Node runtime, prepared the source tree with bare make calls
that fail the whole release on one dropped connection, and built with
a different Python than every pull request is tested with.

- Pin every action by commit SHA, at the major versions tests.yml uses
  (checkout v4, setup-python v5, upload/download-artifact v4, the same
  remove-unwanted-software SHA), and action-gh-release v2. The release
  job holds contents: write, so it should not follow a movable tag.
- Prepare the tree with `python3 -m ci.run_prepare`, as the tests build
  job does. BUILD_TARGET is set from the matrix so `make dir` writes the
  right mozconfig and Rust targets; multibuild.py then finds _READY and
  builds without re-patching. mach's toolchain bootstrap ignores the
  mozconfig, so running it after `dir` bootstraps the same toolchains.
- Build with Python 3.12, the version the tests build job compiles with.
- Default the workflow to no permissions; the build job gets
  contents: read and the release job keeps contents: write.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(python): NewContext looks up a proxy's exit IP through the right URL, or fails

NewContext derives the context's WebRTC IP and timezone from the proxy's exit
IP. That lookup had two defects, and both left the context showing the
host's values while its traffic went through the proxy:

- It built its own proxy URL with urlparse, which reads a scheme-less server
  such as "1.2.3.4:8080" (a form Playwright accepts) as scheme "1.2.3.4" with
  no host. urllib could not use a SOCKS proxy at all.
- Any failure was swallowed, and the context opened without the values.

The URL is now built with Proxy.as_string(), which the geoip launch path
already uses (scheme-less means http). The lookup goes through requests,
which handles SOCKS, and a failed lookup raises InvalidIP, naming the two
options that skip it. The tests cover scheme-less, http and socks5 servers
with credentials, both failure modes, and the case where no lookup is needed,
for NewContext and AsyncNewContext.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: stop generating a canvas seed, and drop config keys nothing reads

The browser has not noised the canvas since #528, and no patch reads
canvas:seed (#721). The launcher still drew one on every launch and sent it
through CAMOU_CONFIG, and NewContext called a setCanvasSeed that does not
exist. They no longer do.

For users this changes nothing on any browser since #528: the value was
ignored. A config that still passes canvas:seed gets the usual "Skipping
unknown patch" notice instead of silence. On a browser from before #528, the
launcher no longer turns canvas noise on, which is the behaviour #528 chose.

The same audit found more keys declared in settings/properties.json that no
patch or Juggler file reads, so setting them did nothing:
- canvas:aaOffset, canvas:aaCapOffset
- memorysaver, pdfViewerEnabled, webrtc:localipv4/6
- navigator.onLine, navigator.cookieEnabled, navigator.languages
- navigator.appCodeName, appName, product, productSub. Firefox reports these
  constants itself, so fpgen.yml no longer maps them.
- webGl:parameters:blockIfNotDefined and its WebGL2 twin

test_config_schema now checks this direction too: every declared key must be
read by the browser, unless it is listed with a reason. Three are listed:
locale:script and navigator.doNotTrack, which the launcher applies itself,
and navigator.buildID (#780).

The build-tester grading followed the same wrong premise. It tracked canvas
collisions as an unfixed per-context leak. A canvas that is rendered rather
than noised follows the fonts and GPU, as it does on real machines, so canvas
collisions are now counted with the other device-level values. The tribal rule
that recorded it as an open question is now a settled one,
canvas-is-not-noised, with an automated check.

Closes #721.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(python): repair devicePixelRatio the same way on every launch

The DPR repair snaps an off-grid ratio to the nearest real scaling step and
keeps the first of two equally near steps. The steps were frozenset
literals, and a frozenset literal iterates in one order when the module is
compiled from source and another when it is loaded back from a .pyc. So a
midpoint such as 1.125 became 1.25 on the first launch after an install and
1 on every launch after it: the same pinned identity presented two different
devicePixelRatio values.

The steps are now ascending tuples, so a tie always goes to the lower step.
The test runs the repair in two fresh interpreters that share a bytecode
cache, compiling in the first and loading in the second. It failed before
this change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: remove the glyph-spacing seed from the browser and the launcher

anti-font-fingerprinting.patch added a seeded amount to every glyph advance,
so that text widths differed per context. No real machine produces those
widths: the same font on the same OS measures the same everywhere. So the
noise was itself a fingerprint, measured in #779 at +1 px per ~100 glyphs
plus fractional deltas on every measureText. #779 defaulted the seed to 0
and kept it as an opt-in, but an opt-in whose only effect is to become
detectable is not worth carrying. Removed:

- The browser side:
  - FontSpacingSeedManager and window.setFontSpacingSeed;
  - the HarfBuzz hook;
  - the plumbing that existed only to carry the context id down to the
    shaper: the userContextId on gfxTextRun, gfxShapedWord and the word-cache
    key, and the extra MakeTextRun argument in nsTextFrame, nsFontMetrics,
    MathML and canvas.
  The font group keeps its userContextId, which font-list-spoofing.patch
  uses to apply the per-context font list. Text is now shaped exactly as
  stock Firefox shapes it.
- The fonts:spacing_seed key. The launcher had been sending 0 on every
  launch, plus a setFontSpacingSeed(0) call in every context's init script.
- tests/patches/config-overrides.py, which tested only the spacing override.
  A pythonlib test now covers config_overrides with another key.

timezone-spoofing, webrtc-ip-spoofing and window-setter-seal change only in
context lines and the setter seal list. Every patch applies cleanly to a
fresh tree, and the result builds. The settled decision is recorded as
no-glyph-spacing-noise, with an automated check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: stock animations and speech by default; drop config keys that freeze live values

Three behaviours a page could detect, changed in one breaking release:

- **Animations run on stock timing.** no-css-animations.patch finished every
  finite animation at once by default, and any page could read it:
  `el.animate(frames, 1000).effect.getComputedTiming().duration` was 0, and a
  500ms transition reported 0. Measured on v152.0.4-beta.31. The speedup is
  now an opt-in, `instantAnimations: True`, which raises a LeakWarning.
  disableInstantAnimations is gone.
- **speak() on a spoofed voice works like a real voice.** It fired `error`
  after 3ms unless voices:fakeCompletion was set, and then start and end in
  the same tick. It now starts and ends after the text's duration at ~150
  words per minute. Both voices:fakeCompletion keys are gone, and so is a
  debug line printed to stderr on every call.
- **Keys removed:**
  - battery:* and window.scrollMinX/Y: Firefox keeps getBattery() and
    scrollMin* chrome-only, so no page could read them.
  - window.scrollMaxX/Y, screen.pageXOffset/pageYOffset,
    window.history.length and document.body.client*: each pinned a live value
    to a constant, so scrolling, navigating or re-laying out never changed it.
    fpgen.yml mapped pageYOffset, so about 15% of identities froze
    window.scrollY at a non-zero value.
  - The body keys' role as an undocumented alias for window.innerWidth/Height
    in browser-init and in the launcher.
  - MaskConfig::GetInt32Rect, which only the body keys used.

New guards, both of which fail on v152.0.4-beta.31:
tests/patches/animation-timing.py and tests/patches/spoofed-voice-speaks.py.
The decisions are recorded as animations-run-on-stock-timing and
spoofed-voices-speak. Every patch applies cleanly to a fresh tree, and the
result builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(python)!: remove dead public API and make `list all --path` work

Breaking changes:

- Remove the exceptions UnknownProperty, InvalidDebugPort and
  MissingDebugPort. Nothing in the package raises them, so code catching
  them was catching nothing.
- Remove the legacy `allow_webgl` keyword of launch_options(). Use
  `block_webgl=True`. The keyword now reaches Playwright as an unknown
  launch option and fails there instead of being silently consumed.

`camoufox list all --path` accepted the flag and ignored it. It now prints
the install path beside each installed build, as `camoufox list --path`
already does for the installed tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(python)!: drop data the package never draws from

voices.json shipped in the wheel, but no code in the package reads it: the
voice draw uses voice-manifests.json and voice-uris.json. Its only readers
are the TypeScript port's golden-fixture generator and data-sync script
(typescript/scripts/golden/identity_golden.py,
typescript/scripts/sync-identity-data.py), which live on another branch and
will need a new source; the last copy is at
676fb3f:pythonlib/camoufox/voices.json. docs/per-context-patches.md
described it as runtime data and now describes the files that are.

webgl_data.db held two rows with zero weight on every OS ("Intel(R) HD
Graphics 400, or similar" from "Intel Inc." and "Radeon R9 200 Series, or
similar" from "ATI Technologies Inc."), left behind when their impossible
macOS weights were zeroed. No draw can reach them. They are deleted with
secure_delete so their blobs do not linger in free pages; the file is not
vacuumed, so the other pages are unchanged. A new test requires every row
to be drawable on at least one OS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(python): warn whenever an identity falls back to a substitute value

Several draws swallowed their failure and used something else, so an
identity could ship with values the rest of it was not drawn to match and
nobody would hear about it:

- from_preset(): a failed font or voice draw used the preset's recorded
  list, or nothing, on any exception.
- generate_context_fingerprint(): a failed font, voice or WebGL draw was
  `except Exception: pass`, leaving the browser's launch-time values.
- _load_font_groups() / _load_font_bases(): an unreadable file became {},
  i.e. no font additions or no OS-version base.
- launch_options(): a failed font draw used every font in fonts.json, a
  failed voice draw used no voices, and a preset GPU missing from
  webgl_data.db was silently swapped for a drawn one (36 of the 397
  bundled presets).

Each site now catches only the errors its data can raise (OSError and
ValueError for an unreadable or corrupt file, KeyError for a manifest with
no entry for the OS, sqlite3.Error for the WebGL database) and emits a
FallbackWarning. The text names what failed and what the identity uses
instead, then gives a block to paste into an issue (camoufox, browser, OS
and Python versions, the error, and the identity's user agent or GPU),
asking the user to report it on GitHub. It shares LeakWarning's
caller-frame attribution and its template lives in warnings.yml.

The broad excepts had also been hiding a broken fixture:
test_launch_environment's font and voice stubs did not accept `seed`, so
every draw there raised and was swallowed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(python): give NewContext identities the browser's Firefox version

NewContext() and AsyncNewContext() passed ff_version=None through to
generate_context_fingerprint(), so a context's user agent kept the version
fpgen drew (e.g. Firefox/146) while the browser underneath was 152. They
now default ff_version to the major version of Playwright's
Browser.version, which Juggler reports from MOZ_APP_VERSION_DISPLAY, so the
UA always names the browser the page is actually talking to. An explicit
ff_version still wins.

The docstrings said each context gets "its own real fingerprint preset";
the default has been an fpgen draw, with a preset only when one is passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(python): send an IPv6 WebRTC address to setWebRTCIPv6

The per-context init script passed every webrtc_ip, IPv6 included, to
window.setWebRTCIPv4(), and never called setWebRTCIPv6(). An IPv6 address
(given directly, or resolved as a proxy's exit IP) was stored as the
context's IPv4 value and the IPv6 slot stayed empty. The script now picks
the setter by address family, and an address that is neither raises
InvalidIP instead of being passed through.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(python): stop pinning the page's scroll offset from fpgen

fpgen.yml mapped the drawn window.pageYOffset (e.g. 528) to
screen.pageYOffset, and the browser returns that value from scrollY on
every read, so a page saw one scroll position forever whatever the user
did. Real scroll offsets are live page state, not part of a device's
fingerprint, so neither offset is mapped any more.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(python): stop checking a config key that no longer exists

warn_manual_config() looked for navigator.languages, which was removed
from settings/properties.json; validate_config() rejects it before the
check could matter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(python): close the WebGL database connection on every path

sample_webgl raised its not-found and wrong-OS errors before reaching
conn.close(), leaking a sqlite connection each time a preset named a GPU the
database does not hold.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(data): drop the 23 presets whose GPU has no WebGL data

A preset records only its GPU's name. The WebGL parameters, extensions and
shader precision behind it have to come from somewhere, and for these 23
nothing Camoufox has describes the GPU: fpgen has never seen Firefox report it
on that OS. So each launch paired the name with another device's parameters,
a mismatch any WebGL fingerprinter can see. They were:

- Windows on ARM (Adreno 650);
- Direct3D 10-level GPUs (vs_4_0/vs_4_1);
- "Generic Renderer";
- 945GM and GTX 480 on macOS;
- nouveau/Mesa buckets on Linux;
- one Linux preset pairing NVIDIA's proprietary vendor string with the
  nouveau renderer name.

scripts/clean-fingerprint-data.py now applies the rule, via a shared
fingerprints.firefox_gpus(), and test_shipped_data asserts it. 374 presets
remain, and every OS keeps its presets. ROADMAP.md lists capturing WebGL data
for these GPUs, which would bring them back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(python): draw every identity's WebGL from fpgen

WebGL vendor, renderer, context attributes, extensions, parameters and
shader precisions, for WebGL1 and WebGL2, now come from fpgen's recorded
Firefox devices instead of webgl_data.db, which is deleted with the
camoufox/webgl/ package.

camoufox/webgl.py:
- webgl_for_gpu() traces `webgl` given Firefox, the OS and the GPU, then
  `webgl2` given the chosen `webgl` too, and draws each with one seeded
  random.Random. The GPU and the webgl value are pinned by their fpgen
  lookup index: a dict condition is flattened into leaves that overwrite
  each other, so only the renderer applied and Linux "Mesa" and "AMD"
  Radeon HD 3200 devices came back mixed.
- sample_webgl_for_screen() draws the GPU of a generated identity from
  fpgen's per-OS weights, filtering out software rasterisers, GPUs the OS
  cannot report, discrete GPUs behind a netbook screen and the
  resistFingerprinting "Mozilla" mask before the weighted choice, so there
  is no rejection loop. An empty pool raises.
- The draft/host-dependent extension filter moves over unchanged.

A preset's GPU and a caller's webgl_config pair are looked up as given;
a pair fpgen has never seen from Firefox on that OS raises instead of
falling back to another GPU. generate_context_fingerprint no longer
falls back to the host GPU when the draw fails.

For 10 of the 15 (GPU, OS) pairs the two sources share, one of fpgen's
records converts to exactly the database row on every value the browser
reads. The other five rows (Linux R9 200 and Radeon HD 3200, macOS
Intel HD, and two software rasterisers) are devices fpgen does not carry;
those GPUs now present fpgen's recorded devices instead. The Linux
GTX 980 row is kept as a test fixture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: say where WebGL comes from now that the database is gone

The per-context guide, the fpgen.yml header and coherence's comments still
named webgl_data.db and sample_webgl(). They now point at camoufox/webgl.py
and fpgen. The guide also claimed presets carry WebGL parameters; they
record only the vendor and renderer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(patches): a host's missing speech daemon no longer errors spoofed speech

On a Linux host where speech-dispatcher cannot start, Firefox broadcasts
synth-voices-error, and SpeechSynthesis answers it by firing `error` on every
queued utterance. So a spoofed Windows voice errored about 11ms into speak()
on any host without the daemon: the CI runners, and most servers. It passed
only where the daemon runs.

While Camoufox manages the voice list, the registry no longer forwards a host
backend's error. The spoofed voices do not depend on the host's engine, and a
Windows or macOS identity never raises one. The guard now makes the daemon
unreachable itself, so it tests this case on every machine; on the previous
build it fails every time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ci: stop skipping the two click tests that stock animation timing fixed

test_wait_for_stable_position and test_timeout_waiting_for_stable_position
were skipped with humanized travel time as the reason. The real cause was
instant animations. Every finite animation finished at once, so the button
Playwright waits on to stop moving never moved, and the click landed where
upstream does not expect. With animations on stock timing both pass, and the
skiplist audit flagged them as no longer failing. The entries go, and the
counts in ci/README.md drop from 14 to 12.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(build-tester): accept 18 and 22 cores, as real hardware reports

plausibleHWC's list of common core counts lacked 18 and 22 -- Intel Meteor
Lake laptops (Core Ultra 5 125H, Core Ultra 7 155H), and 22 is in 8 recorded
presets. build-tester draws random presets, so a run that picked one of the two
Linux presets reporting 22 failed: about one run in eleven, on any pull
request. A CI self-test now fails if the list rejects any core count pythonlib
can present (the presets and PLAUSIBLE_CORE_COUNTS).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(guards): judge the query-cost probes on a median, not one sample

stock-parity-probes timed each getter once. On a shared runner one GC pause or
CPU-steal spike decided the verdict: navigator.hardwareConcurrency took 77 ms
against a 50 ms allowance on the same restored build that passed the run
before. Each pair is now timed five times, interleaved, and compared by median.
The regressions these catch (a sync IPC per read, ~240 ms over the loop) cost
extra on every read, so they move the median; verified by giving the getter a
constant ~4 us of extra work per read -- 86 ms median, FAIL -- while the
healthy build passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(native): compare the whole fingerprint when two launches must differ

test_two_browsers_get_different_fingerprints compared seven coarse values:
UA, platform, screen size, core count, timezone and language. CI pins the
timezone and language, and real machines share the rest: two draws of a
common Mac (Firefox 152, MacIntel, 2560x1440, 8 cores) matched, and the test
failed on a correct browser.

It now reads the whole fingerprint a site computes, from a script in the page:
- navigator values, screen and window geometry, device pixel ratio, timezone;
- WebGL vendor, renderer, limits and extensions;
- installed fonts, measured by width against the generic fallbacks;
- voices, media-device counts, and an OfflineAudioContext hash.
The page is served from an https URL Playwright fulfils locally, because
mediaDevices exists only in a secure context. The page computes the result
itself because the isolated world may not read audio sample data. The test
then requires the fingerprints to differ, and the audio hash to differ on its
own, since its noise is seeded per identity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Update README to remove warning, camoufox is now actively maintained

Camoufox will now be actively maintained and improved for the foreseeable future

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 20:13:35 +00:00
Jake WriterandClaude Opus 5 6daae88dbc Stock-Firefox parity for native identities: input, fonts, locale, WebGL, WebRTC, media, timing, launcher (#779)
* feat(humanize): replay recorded human mouse movements (Cursory)

humanize=True used to walk a Bezier curve through two random knots and emit a
point every 10 ms. Both halves are tells: an analytic curve sampled at a fixed
rate has velocity and jerk profiles that separate cleanly from a hand's, and
every movement accelerated through the same easing function.

Juggler now picks one of Cursory's 2357 recorded human movements whose
direction, distance and wander suit the move, morphs it onto the requested
endpoints and replays it with the recording's own timing. The generator is
cursory-js (a bit-exact TypeScript port of Vinyzu/cursory) vendored under
additions/juggler/input/cursory/; it is LGPLv3-or-later, not MPL-2.0, and ships
its LICENSE and NOTICE inside juggler.jar.

MouseTrajectories.hpp and ChromeUtils.camouGetMouseTrajectory are removed.
sendTrajectoryAcked takes per-step pauses, drops points on the pixel the last
dispatch left the cursor on (a zero-displacement move is never acked), and the
humanize guards are updated for the new path shape.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(patches): shared helpers for binary resolution, a private Xvfb and Marionette

resolve_binary() honours the runner's CAMOUFOX_EXECUTABLE_PATH before falling
back to a Linux objdir (search-service-init and touchscreen-digitizer ignored it
and ran the newest objdir, which after a macOS cross build is an arm64 Mach-O),
hidden_display() gives a guard its own Xvfb so nothing ever opens on the user's
display, and a minimal chrome-context Marionette client lets guards inspect
browser UI state.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(juggler): synthesized input carries what real mouse and keyboard input carries

- pointerType was "" for every Playwright mouse event: juggler dispatched with
  MOZ_SOURCE_UNKNOWN. It now passes MOZ_SOURCE_MOUSE (#776).
- keyboard.type() never pressed Shift: a shifted character now arrives
  bracketed by ShiftLeft keydown/keyup (location 1) with shiftKey set.
- After the pointer was parked off content, pointerover/enter re-entered with
  buttons=1 and pressure 0.5; the tracked position is now forgotten on park.
- A Windows identity gets contextmenu after mouseup with buttons=0, as Windows
  does; GTK/macOS keep it on press.
- Wheel events are sent as line deltas (DOMMouseScroll.detail 3 per notch
  instead of the pixel count).
- The browser rect is measured after the APZ flush await, so a chrome height
  change during the wait cannot put a y==0 dispatch one row above content.
- ci/run_sundial.py moves and clicks the mouse so input vectors have data.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(juggler): evaluate() no longer grants user activation

Upstream Playwright runs every evaluate() as handling user input and notifies a
user-gesture activation. Init scripts go through that path at load, so every
page started with navigator.userActivation.hasBeenActive === true, autoplay
allowed and popups permitted before any input. Activation now only comes from
juggler's trusted input events, as in a stock browser.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(juggler): stop hiding scrollbars in headless

The headless agent sheet set scrollbar-width: none !important, which a page
reads back from getComputedStyle and from overflow:scroll gutters. Scrollbar
appearance is left to the platform look-and-feel (the launcher sets
ui.useOverlayScrollbars per claimed OS).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ui): no visible automation cues in the browser window

- Every Playwright context was a public container, so the URL bar showed a
  "JUGGLER <id>" label and a container colour. Contexts are now non-public
  identities (tabbrowser renders public identities only); startup cleanup
  still removes persisted leftovers.
- showcursor defaulted to true, drawing a red dot that followed the mouse.
  It is now opt-in.
tests/patches/visible-automation-cues.py checks both on a private Xvfb.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(fonts): web fonts, local(), per-character fallback and native bundles

- FontFace / @font-face were answered from the font allowlist by the FontFace's
  own family name, so every url() web font failed with NS_ERROR_FAILURE and
  never rendered, local() of an allowed font failed, and a miss rejected with an
  XPCOM code instead of NetworkError (#759). Stock FontFace/FontFaceImpl are
  restored; local() is filtered by the RESOLVED family in gfxUserFontSet.
- GlobalFontFallback forced the cmap scan, which skips families whose charmap is
  not loaded yet, so any character outside Gecko's script-based common-fallback
  table rendered as the primary family's .notdef (U+1E9E on macOS). The platform
  fallback chooses again, and its choice is held to the mask.
- For a native macOS/Windows identity the bundled font sets are not activated:
  a bundled face of a family the system also has (Papyrus, Helvetica) won the
  lookup with different metrics. On Windows the enumerator still keeps Twemoji
  Mozilla, the emoji font stock Firefox ships (flag emoji drew nothing).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(fonts): CSS2 system fonts and system-ui follow the claimed OS

- The host's own OS gets no system-ui override (macOS resolved system-ui to
  Helvetica instead of -apple-system).
- CSS2 system font keywords use per-keyword faces and sizes; a Linux identity
  reports the Ubuntu desktop font; Windows form controls (-moz-button/field/list)
  answer "MS Shell Dlg 2" as Windows does, not Segoe UI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(fonts): per-OS font model and fontconfig parity

fonts.json is now generated from the bundle by scripts/gen-fonts-json.py
(fc-scan + aliases + scan-time families, intersected with the per-OS manifest in
scripts/data/font-manifests.json) so every reportable family is renderable;
scripts/verify-fonts.py checks that invariant, the generics and the reject
globs. font-groups.json lets the draw keep co-shipped families together.

Linux fontconfig: stock metric aliases (Arial -> Liberation Sans, ...),
49-sansserif, urw-base35 and the non-Latin rule files in stock conf.d order,
generics resolving like a stock Ubuntu (Noto Sans / Noto Serif / DejaVu Sans
Mono / Z003), hintslight so advances are not pinned to whole pixels, and weak
<prefer> lists instead of strongly-bound generic pins so lang can promote a
script face. Windows fontconfig: GDI substitution aliases, MS Shell Dlg 2,
cursive/fantasy generics, duplicate-face rejects and Sitka / Segoe UI Variable
optical-size families.

NOTE: generated against a ~3.9 GB target font bundle that is not part of this
change (one file is over GitHub's 100 MB limit); see docs/FONTS.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(locale): localize browser strings with the spoofed locale; stop rewriting explicit locales

- With locale="fr-FR", Intl/number/date went French but input.validationMessage
  and XML parse errors stayed English, a mix no real Firefox produces. Official
  language packs are now baked in as packaged locales (scripts/fetch-langpacks.py,
  scripts/inject-locales.py, called by package.py, fetched on demand) and the
  launcher selects the UI locale through intl.locale.requested. A langpack
  add-on cannot do this: the parent pre-creates those string bundles first.
- locale-spoofing.patch overrode Language/Script/Region on every intl::Locale,
  so new Intl.DisplayNames(['en'],{type:'region'}).of('DE') returned the spoofed
  region's name and Intl.Locale('ja-Jpan-JP').minimize() returned the spoofed
  tag. Only the OS/default locale is spoofed now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(media): enumerate, capture and label the identity's media devices coherently

The fake media engine now enumerates the identity's microphones, cameras and
speakers (labels and group ids from new mediaDevices:*Labels/*Groups config
keys), MediaManager uses it whenever mediaDevices:enabled, and stock exposure
rules apply: before a grant one device per input kind, no outputs, no labels;
after a grant OS-style labels, distinct deviceIds, shared groupIds. So
enumerateDevices(), getUserMedia() tracks and getSettings() ids agree, and a
claimed camera captures instead of throwing NotFoundError. Fixes the
content-process crash on an identity with a camera and no microphone
(InsertElementAt on an empty array). docs/MEDIA-DEVICES.md; guard
tests/patches/media-devices-coherence.py.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(navigator): globalPrivacyControl agrees between window and workers (#760)

The main-thread Navigator getter ignored the config key that
WorkerNavigator::GlobalPrivacyControl honours, so a page read false in the
window and true in a worker. Both read the key the same way now; the launcher
also mirrors it into privacy.globalprivacycontrol.enabled so the Sec-GPC header
agrees.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(timezone): apply the launch-level timezone from the first read (#773)

The timezone config key was only applied lazily from a navigator getter, so
Intl and Date reported the host zone until a page happened to touch navigator.
It is now applied eagerly in every process (nsJSContext::EnsureStatics) and per
realm when a new inner window is created, entering that window's realm rather
than whichever one triggered the navigation. window.setTimezone() still takes
precedence per context.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(screen): the CSS color media feature follows the spoofed colorDepth

screen.colorDepth was spoofed at the WebIDL level only, so on a 10-bit panel a
24-bit identity reported 24 with (color: 10), a pair Gecko cannot produce.
Gecko_MediaFeatures_GetColorDepth now resolves the depth in the same order as
nsScreen::PixelDepth.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(webgl): pass live state through instead of answering it from the table

getParameter answered everything from the sampled table, so state a page had
just set read back wrong (lineWidth(5) read 1, VIEWPORT/SCISSOR_BOX stayed
300x150 on a 64x64 canvas), extension parameters were null (anisotropy, draw
buffers), COMPRESSED_TEXTURE_FORMATS was null instead of [], and
getContextAttributes() ignored the attributes requested ({antialias:false}
still reported 4 samples). Identity and limits still come from the table; live
state and context attributes are real.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(webrtc): ICE gathering completes behind a proxy (#774)

With Playwright's per-context proxy and
media.peerconnection.ice.proxy_only_if_behind_proxy, ICE failed before
gathering started and iceGatheringState stayed "new" forever, where stock
Firefox completes with host candidates. When that happens around the
fabricated candidates the new -> gathering -> complete state walk is replayed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(patches): refresh window-setter-seal.patch offsets

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(windows): embed Firefox's application manifest in camoufox.exe

config/rules.mk embeds <program>.manifest and browser/app only ships
firefox.exe.manifest, so --with-app-name=camoufox produced an exe with no
manifest. Without the Windows 10 supportedOS GUID the process and its children
run as a pre-Windows-10 application and Gecko's Windows-10-gated paths switch off
(MediaCapabilities.decodingInfo powerEfficient false for H.264/VP9 where stock
is true). The new patch adds a byte-for-byte copy as camoufox.exe.manifest; the
old rename hunk in windows-theming-bug-modified.patch is dropped. Guard:
tests/patches/windows-exe-manifest.py.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(settings): stock values for page-observable prefs; launcher prefs at startup

Page-observable defaults that no stock Firefox has, restored:
- the forced built-in dark theme (it also removed the 1 px nav-bar separator,
  and was applied ~1 s after startup, resizing the viewport) and
  ui.systemUsesDarkTheme (prefers-color-scheme disagreed with the desktop);
- focus rings off, autoplay allowed, popup blocker off;
- gfx.color_management.mode=0 (Playwright's test pref: ICC-tagged images were
  drawn unconverted, readable from a canvas pixel);
- ui.use_standins_for_native_colors (non-native system colours);
- GMP updates off (Widevine/OpenH264 never available);
- storage.estimate() quota derived from the raw disk instead of the stock cap.
The HardwareAcceleration:false enterprise policy is removed: it locked software
WebRender with no hardware video decoding on every OS (guard
tests/patches/hardware-acceleration-policy.py). The minimal-theme chrome.css is
emptied: its ~55 px chrome made outerHeight - innerHeight impossible.

Playwright's non-persistent launch writes no user.js, so launcher prefs only
arrived through juggler after startup and anything Gecko reads while starting
raced (on Windows the UI locale lost 3 of 4 launches). camoufox.cfg now applies
the launcher's CAMOU_PREFS_1..N env chunks as default prefs at startup (guard
tests/patches/startup-prefs.py).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(branding): chrome://branding assets match stock Firefox

chrome://branding/content/ is content-accessible. The wordmark SVGs had
different intrinsic sizes (336x48 / 172x48 vs 300x67) and document.ico,
document_pdf.svg and the private-browsing about logos were missing, all
measurable from a page with an <img>.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(pythonlib): identity draws that match real machines and stay stable

Launcher-side fixes found by comparing camoufox against stock Firefox 152.0.4
on Linux, Windows 11 and macOS hosts:

- DNT / GPC: BrowserForge draws doNotTrack "1" on most Firefox samples, but a
  stock Firefox 152 reports "unspecified" and globalPrivacyControl false; the
  stock defaults are used unless the caller sets them, and both are applied as
  prefs so the API, the worker and the DNT / Sec-GPC headers agree (#760).
- Timezone and geolocation: the timezone is passed to the browser, and a
  configured position sets permissions.default.geo so permissions.query agrees
  with the auto-grant (#769, #773).
- hardwareConcurrency: the reported count is the fingerprint's and the browser
  is pinned to that many cores (cpu_affinity.py, Linux/Windows), so worker
  timing agrees with it; otherwise the host count snapped into the core counts
  real machines ship with (never 2, Firefox's resistFingerprinting value).
- Fonts: the OS base is always present in full, OS-version variants are drawn
  all-or-nothing, co-shipped groups stay together, Cascadia is never claimed
  off Windows, a native macOS/Windows identity claims only the real OS base,
  and gfx.font_rendering.fallback.async is off on Linux so per-character
  fallback does not depend on cmap-load timing.
- Speech voices: a per-OS installed-voice model (voice-manifests.json) with
  the voiceURI formats each backend really produces (voice-uris.json); no
  default voice where stock has none.
- WebGL: extensions a release Firefox never exposes are filtered, but
  OVR_multiview2 stays for Windows D3D11 renderers, which expose it.
- Media devices: a seeded draw of common per-OS devices with OS-style labels.
- Windows scrollbars follow the drawn Windows version (overlay on 11).
- Glyph-advance perturbation (fonts:spacing_seed) defaults to off: it moved
  every measureText width off the value the same font gives on a real machine.
- Launcher prefs are also exported as CAMOU_PREFS_1..N so camoufox.cfg applies
  them at startup, and the browser UI locale follows the spoofed locale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(pythonlib): per-identity salt for seeded draws; core pinning under concurrency

Found in review of the previous commits:

- identity_seed() hashed only the UA, platform, screen size and core count.
  Those take a handful of values per OS, so over 500 launches the seed took
  12-30 distinct values and every install drew its fonts, voices, GPU, media
  devices and canvas/audio noise seeds from that same short list. The seed now
  mixes in identity_salt(): derived from what the caller pinned the identity
  with (a Fingerprint, a preset dict, a config naming the UA) so relaunching
  that identity reproduces every draw, and random otherwise. A pinned preset
  now reproduces its noise seeds too; seeds the caller sets are kept.
- Concurrent AsyncNewBrowser launches on one driver interleaved pin/restore:
  one browser inherited the other's mask and the driver could stay pinned.
  pin -> launch -> restore is serialized per driver.
- Every pinned browser landed on cores 0..N-1; pins now take N adjacent cores
  from a random start.
- A pinnable host with 1-3 cores reported 1, 2 or 3 (2 is the
  resistFingerprinting value); the table floor of 4 applies as on other hosts.
- launch_options() callers that launch the browser themselves (launch_server,
  direct use) kept the drawn core count although nothing pins the browser;
  only Camoufox/AsyncCamoufox pass pin_cpu_cores=True now, everyone else
  reports the host's snapped count.
- PLAUSIBLE_CORE_COUNTS gains 18, 22, 28 and 32, all recorded in the -v150
  corpus.
- The Windows voice list was drawn before the locale was resolved, so an
  fr-FR identity got en-US voices; it is drawn after locale/geoip now.
- macOS "Alex" gets its com.apple.speech.synthesis.voice identifier.
- CAMOU_PREFS env chunks are ASCII-only JSON (Windows getenv goes through the
  ANSI code page).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(fonts): canvas accepts CSS2 system-font keywords; local() works on macOS

- GetSpoofedSystemFontForRFP's per-OS branches returned before marking the
  result a system font. ComputeSystemFont copies that flag into
  FontFamilyList::is_system_font, and without it the canvas font setter could
  not serialize the value: ctx.font = 'caption' (or icon, menu, message-box,
  small-caption, status-bar) was silently ignored and read back
  '10px sans-serif' where stock reads back the keyword.
- CoreTextFontList::LookupLocalFont builds a CTFontEntry with no family name,
  and local() sources are held to the spoofed font list by the resolved
  family, so on macOS every local() face (Helvetica, Menlo, Arial...) failed
  with NetworkError, installed and allowed or not. The entry now carries the
  family CoreText resolved. A blocked lookup's entry is released instead of
  leaked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(webgl): only device limits come from the spoofed table

getParameter still answered ~100 state pnames from the table, so state the page
had just changed read back wrong: UNPACK_FLIP_Y_WEBGL / PREMULTIPLY_ALPHA /
COLORSPACE_CONVERSION after pixelStorei, FRAGMENT_SHADER_DERIVATIVE_HINT after
hint(), DRAW_BUFFERi after drawBuffers(), RED/ALPHA/DEPTH/STENCIL_BITS and
IMPLEMENTATION_COLOR_READ_* for the bound framebuffer, and COMPRESSED_TEXTURE_
FORMATS after enabling an extension. UNMASKED_VENDOR/RENDERER_WEBGL came back
without the extension enabled, where stock returns null with INVALID_ENUM.

The table now answers only the MAX_*/ALIASED_*/SUBPIXEL_BITS limits, WebGL 2
limits on WebGL 2 contexts only, and extension limits (anisotropy, draw
buffers, OVR multiview) only once that extension is enabled; everything else is
the real context's answer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(webrtc): fabricate candidates only where a real gather would have them

With webrtc:ipv4/ipv6 set (every geoip launch), new RTCPeerConnection() with no
iceServers produced a srflx carrying the spoofed IP, and after end-of-candidates
a second host set with a different mDNS name. getStats() exposed that srflx as
id 'camou-srflx' and rewrote every candidate address, including .local host
names and the remote peer's candidates.

A srflx is now fabricated only when the page configured an ICE server, host
candidates only when none reached the page (sharing the real UDP host's port
otherwise), the synthetic stats id has the shape real candidate ids have (8 hex digits,
fixed per connection), and only this side's non-mDNS addresses are rewritten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(media): honour mediaDevices:enabled=false; page fake:true behaves as stock

- MaskConfig::GetBool returns std::optional<bool>, and the checks tested its
  presence: "mediaDevices:enabled": false still enabled the fake devices.
- media.navigator.permission.fake=true was page-readable: a page's own
  getUserMedia({video: true, fake: true}) prompted and never resolved, where
  stock resolves at once with its generic fake device. The pref is off again;
  the identity's devices count as real hardware in the capturing checks
  instead (prompt, sharing indicator, post-grant labels), and a page's
  fake:true request gets stock's generic devices rather than the identity's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(storage): per-context values are session state, and misses are cached

- Values lived on the user pref branch, which a persistent profile writes to
  prefs.js: relaunching with a different timezone (or navigator values) kept
  reporting the previous session's in the page, iframes and workers. They now
  live on the default branch, which is never saved, and reads ignore user
  values an older build left behind.
- A read of an unset key did a synchronous IPC to the parent every time, and in
  a launch without per-context values every read is unset:
  navigator.hardwareConcurrency, screen.* and (color) media queries measured
  ~20x slower than stock. A miss is now cached per key until a pref change or a
  local put clears it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(timezone): no per-realm override for the process-wide zone; cache DateTimeInfo

With a launch-level timezone every new document and worker got a per-realm
override of the zone the process already reported. Setting one releases all JIT
code in the runtime (hot code after adding an iframe ran ~4x slower), and the
realm rebuilt its DateTimeInfo on every call (getHours() ~40x slower than
stock). The override is applied only when the zone differs from the one
JS::SetTimeZoneOverride applied process-wide, and a realm keeps its DateTimeInfo
until its override changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(juggler): wheel scrolls in native notches; Shift leads the key it modifies

- A wheel notch now reaches the page as its own 3-line event carrying one
  native tick (new WHEEL_EVENT_NATIVE_NOTCHES option in
  patches/wheel-native-ticks.patch), so wheelDelta is -120 per notch as with a
  physical wheel; it was -396, and a multi-notch scroll arrived as one event.
  Several notches are spaced a few tens of ms apart.
- Auto-Shift pressed Shift 0 ms before the character's keydown and released it
  0 ms after its keyup; it now leads and trails by a drawn human-scale delay,
  and a failing keydown no longer leaves Shift latched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(settings): stock cookie partitioning, preconnect, popup notification; about dialog CSS

- network.cookie.cookieBehavior 4 (Playwright's) -> Firefox's default 5. With 4 a
  cross-site iframe (captcha and anti-bot widgets are exactly that) sees
  document.hasStorageAccess() true and its first-party cookies and
  localStorage, where stock partitions them. Playwright set 4 so storageState
  need not carry thirdPartyCookie^ permissions.
- network.http.speculative-parallel-limit 0 turned <link rel=preconnect> into a
  no-op, visible in Resource Timing.
- privacy.popups.showBrowserMessage false: stock shows a notification bar for a
  blocked popup, which shrinks the viewport and fires resize.
- chrome://branding/content/aboutDialog.css is page-loadable and was empty; it is
  the official branding's now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(patches): stock-parity probes for the leaks found in review

One launch (plus two persistent relaunches) checks page-observable invariants
stock Firefox 152 holds: canvas CSS2 system-font keywords, WebGL state readback
and UNMASKED_RENDERER without the extension, no srflx without ICE servers and
no 'camou' stats id, getUserMedia({fake: true}), cross-site storage
partitioning, wheel notches, per-read cost of (color)/hardwareConcurrency and
of local Date getters under a launch timezone, and a persistent profile's
timezone after relaunch (page and worker). Run against the build before these
fixes it fails on every one of them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(humanize): judge cadence by distinct values and spread, not a share of the gap count

The page clock is clamped to 1 ms and Cursory's recorded steps mostly sit
between 12 and 20 ms, so the number of distinct gap values cannot grow with the
number of gaps. Requiring len(gaps) // 4 made the guard fail on visibly uneven
runs whenever event delivery was steady (3 of 4 runs once the per-read sync IPC
jitter was gone). A fixed 10 ms cadence yields about three values within a few
ms of each other, which the new rule (>= 6 values, >= 20 ms spread) still fails.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(juggler): restore the popup, wheel and history contracts

The Playwright suite went red on this branch, and five of its six shards ran out
their 40-minute budget before reporting, so ~470 sync tests were never run at
all. Three causes, all ours:

- window.open() from page.evaluate() returned null. The popup blocker being ON
  (Firefox's default) and evaluate() no longer granting user activation are each
  defensible alone; together they block every gesture-less popup. ~35 tests, each
  burning 30s x 4 attempts x 2 worlds, which is what exhausted the shards.
  The blocker goes back to Playwright's and geckodriver's value. Reading it costs
  a detector a popup window the user sees, so it is not a check an anti-bot
  script in the page runs -- unlike navigator.userActivation.hasBeenActive, which
  is one property read, and which is why the activation half stays.

- mouse.wheel(0, 100) delivered deltaY 114 (or 132, depending on the host's font
  metrics) in deltaMode 1. Quantising into native wheel notches is what a
  physical wheel does, but it changes the number the caller asked for, so it now
  rides behind humanize= with the rest of the humanized input. Default is the
  exact requested delta in deltaMode 0.

- page.go_back() did nothing after history.pushState(). canGoBack is the BACK
  BUTTON's answer: under browser.navigation.requireUserInteraction it reports
  false when every entry behind this one was pushed without the user touching the
  page, which is now every entry, because evaluate() grants no activation.
  goBack() itself does not skip those entries and neither does history.back(),
  so ask canGoBackIgnoringUserInteraction, as Marionette does.

Two keyboard tests are skiplisted rather than fixed: auto-Shift means typing "!"
emits the Shift a US keyboard requires, and upstream asserts the character's
three events with shiftKey false throughout. The character's own key/code/keyCode
are unchanged; what upstream asserts is the absence of a Shift no real typist
could omit.

Full suite against the fixed build: 2223 passed, 6 failed -- the two keyboard
tests above, and four client-certificate tests that fail only on this machine
(Node/OpenSSL rejects the fixture server) and pass in CI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(pythonlib): stop overriding the corpus on core counts; pinning is opt-in

Two findings from auditing the sweep's fixes against one bar: a difference is a
leak only if a page's JavaScript can actually read it.

hardwareConcurrency 2 was excluded from PLAUSIBLE_CORE_COUNTS because "2 is what
Firefox reports under resistFingerprinting". That has not been true for years:
RuntimeService::ClampedHardwareConcurrency hardcodes 4, and 8 on macOS, both of
which are already in the table. The exclusion protected against nothing and cost
every genuinely dual-core machine -- 20% of the macOS presets in the recorded
corpus, 4.2% of Linux draws. It also made the small-host tail worse: a 3-core
host reported 4, which cannot be pinned, so a page measured 3 while being told 4.
At 2 the pin succeeds.

pin_cpu_cores now defaults to False. What it buys is defence against a page
timing N parallel workers; what it costs is a browser-wide CPU cap, a per-driver
launch lock, and nothing at all on macOS. Unpinned, the host's own snapped count
is reported, so reported and measurable still agree -- the identity just loses
one drawn value. Callers who want the draw kept can still ask for it.

The WebGL sampler keeps rejecting software rasterisers, and its docstring now
says so: it described the opposite of what the code does. llvmpipe as the
presented GPU is a live check on a string every fingerprint script reads, which
is worth ~1.5% of corpus fidelity.

251 pythonlib tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(pythonlib): keep 2 out of the core table -- an Apple M1 is never dual-core

Reverts the PLAUSIBLE_CORE_COUNTS half of fd501e5. The conclusion there was
wrong, even though the fact that prompted it was right.

Right: "2 is what Firefox reports under resistFingerprinting" is false, and has
been for years. RuntimeService::ClampedHardwareConcurrency hardcodes 4, and 8 on
macOS, both already in the table.

Wrong: concluding from that, and from the corpus recording 2 on 20% of macOS
presets, that 2 should be drawable. 85% of macOS identities draw
"Apple M1, or similar" as the WebGL renderer, and no Apple Silicon part has ever
had fewer than 8 cores. A page reading navigator.hardwareConcurrency and
UNMASKED_RENDERER_WEBGL together -- two property reads, both already in every
fingerprint payload -- would see a machine that does not exist.

The corpus frequency is not counter-evidence. Those rows report 2 more often
than 4 on macOS (11 vs 3), which no real hardware population does: the corpus is
scraped from live traffic, so it carries privacy-hardened browsers, 2-vCPU VMs
and other people's bots. The corpus settles what real machines report where the
field is hardware; hardwareConcurrency is a number a browser can be made to say.

The comment now records the true reason, so the next reader does not undo this
by discovering the RFP claim is false -- which is exactly how it came undone.
pin_cpu_cores stays opt-in, and the WebGL software-rasteriser rejection is
unchanged. 251 pythonlib tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(pythonlib): a preset naming an unknown GPU must not fail the launch

The pythonlib job failed on a Windows preset whose GPU is
"ANGLE (Unknown, Adreno (TM) 650 Direct3D11 vs_5_0 ps_5_0)" -- a phone GPU, in
the Windows pool. sample_webgl raised, and launch_options() with it.

The crash is not new here: upstream/main has the same branch, which looks the
preset's vendor/renderer up in webgl_data.db to get the parameters that belong
to it. What is new is a test that draws a RANDOM preset, so it surfaced as a
1-in-11 flake instead of a deterministic failure. 39 of the 435 bundled presets
name a GPU that is not among the 33 in the database, so ~9% of preset launches
have always raised -- and a caller passing their own preset dict had no way to
know which pairs are supported.

The named GPU cannot be kept: parameters, extension list and shader precisions
all have to come from one real recorded device, and there is none for an unknown
renderer. So the fallback draws a GPU that fits the screen and REPLACES the
pair. Replacing it needs the two keys popped first, because merge_into does not
overwrite what the preset already set -- without that the page reads
"Adreno (TM) 650" with a desktop GPU's parameters behind it, which is a louder
mismatch than the one being fixed. Measured on the bundled presets: 30 of 312
now swap, e.g. a macOS preset claiming a 2008 Radeon HD 3200 becomes Apple M1.

Guarded by a parametrised test over EVERY bundled preset in all three pools,
asserting the pair that survives is one the database knows. It fails without the
fix. 254 pythonlib tests pass.

The rows themselves are corpus contamination and worth cleaning separately: the
presets are scraped from live traffic, so the Windows pool carries an Android
GPU and the macOS pool carries GPUs no Mac has shipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(patches): check both wheel modes, not just the notched one

The stock-parity guard asserted that mouse.wheel(0, 300) arrives as three
notched events. That is now the humanize=True behaviour, not the default, so the
guard failed on the build it was meant to certify -- it encoded one side of a
decision that has two sides.

It now checks both: with humanize off, one event carrying the delta the caller
asked for (deltaMode 0, deltaY 300); with humanize on, three events whose
wheelDeltaY is a multiple of 120, as a physical wheel produces. A second short
launch covers the humanized half, in the style of the timezone relaunch probe.

Verified against the local build: PASS on every probe, with the humanized scroll
arriving as 3 events of wheelDeltaY -120.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(patches): the query-cost probe measured the JIT, not the IPC

query-cost failed in CI with "hardwareConcurrency 19 ms vs userAgent 0 ms". The
0 ms is the tell: the loop reads a getter and discards the result, so the JIT
elided the BASELINE loop entirely on that runner. With the baseline at zero the
check `costHwc > 5 * costUA + 15` collapses to a flat 15 ms allowance for 20000
reads -- 0.75 us each -- while a healthy read of a value that lives in the config
costs ~1 us. It was timing whether the JIT dropped the loop.

Every read is now accumulated into a sink that is returned, so the loop cannot be
optimised away. (userAgent still measures ~0 because the string is cached, hence
the second change.)

The allowance on the two config-read checks goes to 40 ms. The state they guard
against is a sync IPC per read, measured at ~12 us each when it regressed, i.e.
~240 ms over this loop; a healthy read is ~20 ms. 40 sits an order of magnitude
under the defect and clear of a slow runner.

The timezone-cost check keeps its 15 ms allowance and is commented to say why:
its healthy numbers are ~2 ms vs ~1 ms and its regression is ~20 ms over the same
loop, so widening it to 40 would step over the very thing it exists to catch.

Verified against the local build: PASS, costHwc 7-13 ms, costLocalDate 1-2 ms.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(pythonlib): generate fingerprints with fpgen instead of BrowserForge

BrowserForge (and the Apify fingerprint-suite data behind it) is replaced by
fpgen -- scrapfly/fingerprint-generator, Apache-2.0, same author as Camoufox,
trained on Scrapfly's live traffic.

The reason is coverage. Measured over 120-200 Firefox draws per OS:

                        BrowserForge / Apify      fpgen
  distinct GPUs         2-3 per OS                6-9 sampled, 999 in the
                                                  value space (webgl_data.db
                                                  has 33)
  fonts                 4-22 names                539-802 on macOS, 87
                                                  distinct sets on Windows
  media devices         always empty for Firefox  counts (labels are not
                                                  collectable -- see below)
  voices                absent                    real lists with voiceURIs
  system fonts/colours  absent                    per keyword, per OS
  WebGL params          absent                    params, extensions,
                                                  shaderPrecisionFormats,
                                                  contextAttributes
  audio                 absent                    1238 distinct hashes

This commit is the swap alone: fpgen supplies exactly what BrowserForge did --
navigator, screen, window geometry, Accept-Encoding -- through a new fpgen.yml
mapping. The richer fields are NOT wired up yet; Camoufox still draws WebGL from
webgl_data.db and fonts/voices from its own catalogues. That is the follow-up,
and the one with the real diversity win in it.

Notes for callers:
- `camoufox.fingerprints.Screen` replaces `browserforge.fingerprints.Screen`,
  same four bounds. It becomes an fpgen predicate rather than a filter, and an
  unsatisfiable bound (a 1x1 Xvfb) falls back to an unbounded draw, as
  BrowserForge did by silently dropping the constraint.
- `fingerprint=` now takes an fpgen dict, not a browserforge Fingerprint.
- `from_browserforge()` is now `from_fpgen()`.
- fpgen fetches ~7 MB of model data from GitHub on first use, so a fully offline
  install cannot generate a fingerprint. Presets and caller-supplied configs are
  unaffected.
- doNotTrack is deliberately unmapped: Camoufox owns DNT, and a drawn value was
  being stripped anyway.
- fpgen's own draws still need the coherence fixes -- conditioned on an Apple M1
  renderer it still returns hardwareConcurrency 2 in 12% of draws. Changing the
  source does not remove the need for fix_hardware_concurrency and friends.

254 pythonlib tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(pythonlib): every identity passes a whole-identity coherence check

Camoufox assembles an identity from pools sampled independently -- navigator and
screen from the generator, GPU from webgl_data.db, fonts and voices from their
own catalogues. Nothing compared them, so a machine that never existed could be
built out of parts that are each fine on their own. Cleaning the pools cannot
fix that: the incoherence is created at composition.

Measured before this, per 200 generated identities:
  - 14% of macOS identities drew "Radeon R9 200 Series, or similar", a desktop
    PC card, or "Intel(R) HD Graphics 400", a Braswell Atom IGP. Both are in
    webgl_data.db's macOS column at 3.7% and 7.4%; neither shipped in a Mac.
  - 7% paired Apple Silicon with colorDepth 24. Deep colour is the macOS
    default, measured 30 on the Mac mini.
  - 2% of Windows identities reported maxTouchPoints 256.
  - 18 of the 312 bundled presets carried a GPU or a screen no desktop has,
    including a 736x414 iPhone viewport and a portrait 1440x2560.

coherence.py states each invariant as a rule with the measurement behind it,
repairs what has a determined correct value, and reports what does not. It runs
on every identity whatever built it -- generated, preset, or caller-supplied.
Where a value can be replaced rather than repaired it is dropped BEFORE the pool
that would defer to it (a preset's own GPU pair wins over sampling, so an
impossible pair is dropped and sampling draws a coherent one), and
webgl_data.db is filtered by the same predicate before sampling, so the check
and the draw cannot disagree about what a Mac may claim.

After: 0 violations across 600 generated identities and all 312 presets.

Guarded by tests/test_coherence.py, which checks each rule against the value
that motivated it, asserts the three machines captured on 2026-09-17 pass as
themselves, and walks every bundled preset.

test_preset_screens_are_never_lifted asserted that a preset IS a real device and
its screen must never be rewritten. That premise does not survive the data, so
the test now distinguishes a small desktop panel, which is still kept, from a
phone viewport, which is repaired.

273 pythonlib tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(pythonlib): filter incoherent identities out of the shipped data too

The coherence layer filters when an identity is drawn. This filters the data it
is drawn from, so an impossible row never reaches a build: belt and braces, and
the two use the same rules.

scripts/clean-fingerprint-data.py checks each preset the way a launch converts
it, and each webgl_data.db pair against the OS weights it is offered under.
Presets are DROPPED rather than repaired -- repairing would write an invented
value ("what core count does a 2-core Apple M1 really have?") into a file whose
whole purpose is being real. GPU rows are kept with the impossible OS weight
zeroed, because "Radeon R9 200 Series" is a genuine Linux and Windows card that
simply never shipped in a Mac.

Removed, with --write:
  - 38 of 435 presets: 27 with a GPU their OS cannot report, 7 pairing Apple
    Silicon with a core count Apple never shipped (2, 18), 4 with a colour depth
    their GPU contradicts, 3 with a phone viewport (736x414, 960x540, portrait
    1440x2560), 1 claiming 40 touch points, 1 whose renderer is "Mozilla".
  - 2 impossible macOS weights in webgl_data.db (Intel HD Graphics 400 at 7.4%,
    Radeon R9 200 Series at 3.7%).
  macOS loses the most: 97 presets -> 63. Windows 255 -> 251, Linux 83 -> 83.

tests/test_shipped_data.py asserts the files stay clean, so a refresh that
reintroduces a bad row fails CI instead of shipping, and that no OS pool is
emptied by the filter -- one GPU per OS would be its own tell.

Two rules were loosened after checking them against real hardware rather than
against the pools: maxTouchPoints is now a range (0..10) instead of a list of
values seen in scraped data, because fpgen's Windows pool never offers the 5
that win-i9 actually reports; and APPLE_SILICON_CORES gained 11, which is the
M3 Pro's 6P+5E. Over-filtering costs realism as surely as under-filtering.

A new device-pixel-ratio rule snaps scraped artefacts (1.818, 1.09) to the
nearest real scaling step, and rejects fractional DPR on macOS, which has none.
The three captured machines (dpr 1 / 2.5 / 2) pass as themselves.

The writer reproduces each file's own formatting byte-for-byte on a no-op run,
so the diff of a real run is the dropped rows and nothing else. Verified: every
surviving preset is unchanged and the metadata is untouched.

278 pythonlib tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: point CLAUDE.md at the coherence layer and the data cleaner

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci: a pythonlib data tool must not cost an hour of compiling

The build cache is keyed on a hash of the inputs that can change compiled
output, and scripts/ is hashed wholesale because it holds the build machinery
(patch.py, copy-additions.sh, package.py). It also holds tools that only rewrite
the PYTHON package's data files, and those pay the same price: adding
scripts/clean-fingerprint-data.py, which edits pythonlib JSON, invalidated a
665 MB cached browser and bought a full rebuild of a browser whose sources had
not moved. Measured on this branch -- the only browser-input file changed across
the last five pushes was that script.

NON_NATIVE_SCRIPTS names the exceptions. Nothing is excluded for looking
unrelated: excluding a script a build DOES run is the dangerous direction, since
the cache would then serve a browser built from different sources while every
suite downstream passed against it. So ci/tests/test_ci.py checks each entry
against the files a build enters through (Makefile, multibuild.py, patch.py,
package.py, copy-additions.sh, _mixin.py) and fails if one is reachable, and a
second test fails if an entry no longer exists -- a stale exclusion stops
excluding anything, quietly.

Verified against the real cache: a clean checkout of 77edaeb hashes to
fa62d4be1bdff96eee6e9018552255c4, which is the browser cached for this pull
request, and a clean checkout of HEAD with this change hashes to the same value.
So the next run restores that browser instead of compiling one.

This does not change `browser_changed`, which asks a different question (build
versus fetch the published release, against the pull request's base) and is
correctly true for every push to a branch that has touched the browser once.

169 ci tests, 278 pythonlib tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(pythonlib): keep the window's inner dimensions real, and its chrome honest

Three patch guards failed after the fpgen switch -- humanize-edge-deadlock timed
out at the full 600s, humanize-mouse-trajectory saw only its first endpoint, and
mouse-boundary-sweep lost 15 of 25 ring points. All three are geometry, and both
causes were mine. The same guards pass on the pre-fpgen pythonlib with the same
binary, which is how the two were separated from a browser fault.

**Inner dimensions must stay real.** Playwright's setViewportSize resizes the
window and then waits for the page to report the size it asked for. A spoofed
innerWidth/innerHeight never changes, so that wait never returns -- the trap
no_viewport already exists for (#666), reached here through an explicit
set_viewport_size() call. BrowserForge hid it by accident: its Firefox samples
carry innerWidth/innerHeight as 0, and _cast_to_properties skips falsy values,
so they were never spoofed. fpgen reports the real numbers, and mapping them
turned an unmapped field into a hang. They are no longer mapped, which also
means the content area a page measures is the one it actually has.

**The claimed chrome height cannot be smaller than the real one.** The window is
sized from window.outerHeight, so the content area that can receive input is
outerHeight minus the browser's own 86px of chrome. An identity claiming
`outerHeight - innerHeight` below that claims a viewport taller than the window
can hold, and the difference is dead: mouse events dispatched into those rows
reach nothing. Measured -- a drawn pair of outer 801 / inner 717 (chrome 84)
left the bottom 2px unreachable, which is exactly what the boundary sweep saw,
always on the bottom two rows. browserforge's pairs were never below 86; fpgen's
sometimes are. New coherence rule, repaired by growing the window where the
screen has room and shrinking the viewport where it does not.

humanize-mouse-trajectory also had a latent bug of its own: it moved to a flat
(1100, 650), which silently tested nothing whenever the drawn window was smaller
-- the move landed outside the content area and the run failed reporting only
the start point. One drawn window was 924x1364, narrower than that x. It now
derives the destination from the viewport it actually got.

22/22 patch guards pass locally (5m37s); 279 pythonlib tests pass, including a
regression test that no generated config carries window.innerWidth/innerHeight.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(fonts): draw a real OS-version base, at measured real-world rates

The font draw modelled a machine as "always-present core + a flat 30-78%
sample of everything else". Neither half held up:

  * the sample was UNIFORM, so every addition was equally likely. Office
    (on ~60% of real Windows machines) and the Pan-European supplemental
    pack (~2.8%) were drawn at the same rate, and `kind`, `prob`,
    `requiresLocale` and `sizes` from scripts/data/font-manifests.json were
    thrown away when font-groups.json was written by hand.
  * `_ESSENTIAL_FONTS_MACOS` was a SUPERSET of one base (553 entries), which
    silently forced 131 Sonoma-only families onto every macOS identity. It
    has to be the INTERSECTION of that OS's bases, or adding a second base
    does nothing.

A machine now draws one OS-version base, whole and never subsetted, then
each addition unit independently at its own probability.

The bases are measured from real sources rather than inherited:

  Ubuntu 24.04 / 26.04   official desktop ISO manifest -> the shipped .debs
                         -> fc-scan. Firefox enumerates via fontconfig on
                         Linux, NOT nameID 1: the same file reports
                         "Noto Sans MeeteiMayek" in nameID 1 and
                         "Noto Sans Meetei Mayek" in fontconfig. The old base
                         named 26 families Ubuntu does not ship and missed 41
                         it does.
  Windows 11 26200.9457  a real box. Office is installed there, so OS-native
                         files are separated by WinSxS hardlink (an OS font
                         has one, an Office font does not): 143 of 340 files.
                         Scanning English-only drops the 17 localized CJK
                         names, so all language ids are kept.
  macOS 26.6.2 / 27.0    a real Mac, verified clean. They differ by three
                         families: 27 drops Noto Sans Brahmi and
                         Noto Sans CanAborig and adds Noto Sans Sunuwar.
  macOS Sonoma           NOT re-verifiable (that machine has since been
                         upgraded). Six family names were stored mojibaked
                         (Shift-JIS read as UTF-16BE) and are repaired here.

Windows 10 is dropped (end of support Oct 2025), so win11 carries weight 1.0
and the Win11 families are part of the base; the NATIVE path now subtracts
them on a Windows 10 host instead of adding them on a Windows 11 one.

Apple ships 189 families as optional Font Book downloads rather than enabled
by default. The fpgen corpus independently puts all 57 reportable ones at
exactly 81.8%, so they are an `apple-optional` unit, not base. Dot-prefixed
macOS families are stripped: CoreText excludes them from enumeration.

Adobe's fonts are removed from the bundle (185 files, 240 MB): Adobe permits
no redistribution, and the corpus puts every Adobe family on under 2% of real
machines, so they bought no realism. The `adobe-cc` unit goes with them,
because reporting a family the bundle cannot render is a reverse leak.

font-groups.json and the new font-bases.json are now GENERATED by
scripts/gen-font-groups.py instead of maintained by hand, which is what keeps
the draw's probabilities equal to the manifest's.

tests/test_font_distribution.py asserts the properties verify-fonts.py cannot:
complete-base containment, base weights, per-unit probability, bundle
atomicity, a-la-carte piecemeal sizing, locale gating, and variation. Checked
by mutation: reintroducing the uniform sample fails 3 tests, collapsing
variation fails 14, truncating a base fails 1.

The font bundle itself is deliberately not in this commit: it is 3.7 GB and
one file is 184 MB, over GitHub's push limit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(fonts): store each face once, fetch the bundle as a release asset

The bundle was three per-OS directories, so a face used by more than one OS was
stored more than once: 2955 files, 3.96 GB, of which 1.63 GB (41%) was
byte-identical copies. That was not a decision, it was the absence of one --
the DIRECTORY was the only selection mechanism, so fontconfig could be scoped to
an OS only by giving that OS its own full copy of everything it needed.

Each face is now stored once, in a directory named for the set of OSes that use
it (L, M, W, LM, LW, MW, LMW). An OS reads the four groups its letter appears
in; bundle/fonts/groups.json records the mapping and
utils._generate_fontconfig hands fontconfig exactly those directories.

    1513 faces, 2.16 GB
    lin -> L+LM+LMW+LW  (792)   mac -> LM+LMW+M+MW (779)   win -> LMW+LW+MW+W (809)

    package        before    after
    linux          3.96 GB   2.16 GB   (-46%)
    macos          2.21 GB   1.31 GB   (-41%)
    windows        2.69 GB   1.79 GB   (-34%)

pythonlib/camoufox/fonts.json is BYTE-IDENTICAL across the change: the same
families are renderable and reportable on all three OSes. The saving is pure
redundancy.

The group directory is also a better gate than what it replaces. The 455
<rejectfont> globs in fontconfig/windows/fonts.conf are gone: a face Windows
must not see is simply not in a group Windows reads (verified: 0 of the 243
formerly rejected basenames appear in any Windows group). Those globs were
unsound anyway -- 114 bundled filenames contain [ ] (e.g. ReemKufi[wght].ttf),
which fontconfig parses as a character class, so they silently matched nothing.
macOS (CoreText) and Windows (DirectWrite) cannot read subfolders, so package.py
flattens the groups for those targets and the allowlist gates them, as before.

The bundle itself leaves git. It is 2.16 GB extracted and 843 MB as .tar.xz;
GitHub rejects files over 100 MiB, and an xz archive cannot be delta-compressed,
so committing it -- split into ~9 parts -- would append the whole archive to
history on every font change, paid by everyone who clones the repo. It is now a
release asset in its own tag namespace (font-bundle-*, excluded from build.yml
so it does not trigger a browser build), fetched on demand:

    make fetch-fonts     download + verify   make fonts-extract   unpack
    make fonts-check     verify only         make fonts-clean     drop the unpack

scripts/data/font-bundle.json pins the asset name, size and sha256, so a commit
still names exactly one bundle and a truncated download fails loudly instead of
producing a browser that reports fonts it cannot render. This is the trust model
the build already uses for the Firefox source (`make fetch` pulls a ~500 MB
tarball from archive.mozilla.org), so a clone was never buildable offline.

Only the compressed archive is kept; bundle/fonts/ is extracted on demand and is
gitignored. Round-trip verified byte-identical. package-* now depend on
fonts-extract, and gen-fonts-json.py / verify-fonts.py fail with the fix
("run make fonts-extract") rather than a stack trace.

642 font blobs (974 MB) leave the index. bundle/fonts/000_README.txt moves to
bundle/FONTS-README.txt and cleanfonts.sh to scripts/, since bundle/fonts/ is
now ignored.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci(fonts): fetch the font bundle before packaging

The bundle stopped being repo content in the previous commit, so a tagged build
would package a browser with NO fonts at all while pythonlib/camoufox/fonts.json
still reports 335-567 families per OS -- every one of them a family the browser
cannot render, which is the reverse leak this whole series exists to remove.

`make fonts-extract` downloads and unpacks it (verified against the sha256 in
scripts/data/font-bundle.json), and verify-fonts.py then asserts the bundle and
the manifest actually agree before anything is packaged. A build that would have
shipped a mismatched font set now fails in CI instead.

Net disk in CI goes DOWN: 3.96 GB of fonts from the checkout becomes a 0.84 GB
archive plus 2.16 GB extracted.

The tests workflow is untouched: it reads the generated JSON, not the bundle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(fonts): drop the stale README the v1 archive still carries

bundle/fonts/000_README.txt and cleanfonts.sh were inside the bundle when the v1
archive was built, and they moved to bundle/FONTS-README.txt and scripts/ in the
same change that took the bundle out of git. Extracting therefore re-created a
stale copy of a file git now owns -- invisible to git (bundle/fonts/ is ignored)
but contradicting the tracked README, and it would have been folded back in if a
later bundle were rebuilt from an extracted tree.

Prune both after extraction. A future archive will not contain them, at which
point this is a no-op.

Extraction verified reproducible: two consecutive `make fonts-extract` runs
produce a byte-identical 1514-file tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(fonts): stage the groups, not per-OS dirs that no longer exist

scripts/stage-fonts.sh copied bundle/fonts/{linux,macos,windows} into an
unpackaged build's dist/bin. Neither half of that is true any more: the bundle
stores each face once under a group directory (L, M, W, LM, LW, MW, LMW) and is
a release asset rather than repo content, so on a fresh checkout the source
paths do not exist and bundle/fonts/ itself does not either. Under `set -e` the
cp fails, which fails `make stage-fonts`, which fails the "Package the binary
for the test jobs" step in tests.yml -- so the build job, and with it the
required gate, would have gone red on every pull request. Locally it broke the
patch guards and build-tester the same way.

Stage the group directories verbatim instead, plus groups.json, which is what
utils._generate_fontconfig reads to decide which of them a claimed OS may see.
Staging the groups rather than a flattened copy is what makes the per-OS gate
work against an unpackaged build exactly as it does in a package. groups.json is
copied last, so an interrupted copy leaves no marker and the next run stages
again instead of trusting a partial tree; the group dirs are copied by name so
fetch-fonts.py's .bundle-sha256 bookkeeping file stays out of a browser's font
directory.

`stage-fonts` now depends on `fonts-extract`, since a fresh checkout has nothing
to stage from. To keep that cheap enough to run before every launch,
fetch-fonts.py stamps the unpacked tree with the archive's sha256 and `--extract`
returns immediately when it matches: 30s to 0.03s, and it no longer needs the
843 MB archive to still be on disk. A bundle bump changes the sha256, so a stale
tree is replaced rather than trusted.

The artifact check in tests.yml asserted fonts/linux; it now asserts
fonts/groups.json and fonts/LMW.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(fonts): prove no identity can reach another OS's faces

verify-fonts.py handed every OS the bundle ROOT as its fontconfig <dir>.
fontconfig scans <dir> recursively, so all three OSes were being tested against
all 1513 faces -- which is why each reported an identical "fc-list publishes 1307
families". The per-OS group gate, the thing that replaced the 455 Windows reject
globs, was therefore never checked by anything.

Hand each OS the four group directories it actually reads, as
utils._generate_fontconfig does, and then assert the converse of the existing
invariant: ask fontconfig for every file it can reach under that conf and require
all of them to sit inside that OS's own groups. The existing checks only prove an
OS can render what it reports; nothing proved it cannot reach what it must not,
and no reported name would reveal it -- a Windows-only or macOS-only face on the
search path is a glyph-fallback candidate, so an emoji or CJK glyph could resolve
to Segoe UI Emoji or PingFang on a machine claiming Linux.

The gate holds: 810 / 780 / 793 faces reachable for win / mac / lin, all inside
their own groups, and the per-OS family counts are now honestly different (529 /
872 / 583 published against 335 / 567 / 358 reported). Mutation-tested -- putting
the root back makes all three fail with 1513 reachable faces.

Also in build.yml: install dependencies before fetching the bundle, so the 843 MB
download uses aria2c's parallel connections instead of silently falling back to
single-connection curl, and add fontconfig explicitly since the verify step
resolves every reportable family through fc-list/fc-match. The over-100-MiB
report is no longer a warning: it is the settled reason the bundle is a release
asset, not an outstanding problem.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(fonts): describe the bundle that actually ships

docs/FONTS.md still described a world several changes back: one bundle per OS at
bundle/fonts/{linux,windows,macos}, a "target bundle" not yet in git, a Windows 10
base, Sonoma as the only macOS base, a uniform 30-78% draw, the 455 reject globs,
Adobe CC among the additions, and a Linux package that duplicates fonts. Every one
of those is now wrong, which makes the document worse than no document.

Rewritten against the shipped data, with the figures read out of the files rather
than recalled: the release-asset workflow and the sha256 pin, the group layout and
which four groups each OS reads, both invariants (reported ⊆ renderable, and
nothing outside an OS's groups reachable) and which one each package type relies
on, the per-OS base weights and the per-unit bundle / à-la-carte probabilities as
tables, and the resulting draw sizes. The namespace trap that produced two wrong
font lists during this work -- Windows/macOS enumerate nameID 1, Linux enumerates
through fontconfig, and system_profiler/WPF report nameID 16 -- is written down so
it is not rediscovered a third time.

Known residue is now stated rather than implied: the Linux base over-claims
against the corpus because the bundle ships 30-metric-aliases, the macOS base
weights are estimates where the addition probabilities are measured, nothing has
been checked in a running browser, and font-bundle.json points at a release on
this fork, which has to be re-uploaded and re-pinned before upstream can build.

Also: per-context-patches.md described the font staging as OS subdirectories and
named createRuntimeFontconfig(), which does not exist anywhere in the tree (it is
utils._generate_fontconfig); gen-fonts-json.py's header still said it scanned
bundle/fonts/<os> and listed Adobe CC; licences.py pointed at the README's old
path inside the bundle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(fingerprints): pin fpgen's model, and close the taskbar gap it exposed

fpgen supplies every synthetic fingerprint, and it does not ship its model --
it downloads one on first import, and again whenever the files are over five
weeks old. Four things are wrong with that fetch, all in fpgen/pkgman.py:

  * TLS verification is disabled on BOTH the API call and the download
    (verify=False), so anyone on the path can serve the model;
  * the archive is never checksummed, and goes straight into extractall()
    with no path-traversal guard;
  * the GitHub API is called unauthenticated, on a rate limit shared by every
    job on the runner's IP;
  * it takes the FIRST release the API lists. model-4/2025 and model-2/2026
    carry an identical created_at (2025-03-22, inherited from the tag's
    commit), so the sort ties and resolves to the lower id -- model-4/2025.

The consequence is that every Camoufox generates from an April-2025 corpus and
cannot be talked into anything newer: newest Firefox 137, newest GPU an RTX 40,
no RDNA4. The 2026 model has been sitting unreachable for seven months.

scripts/pin-fpgen-model.py installs the model named by scripts/data/fpgen-model.json
before anything imports fpgen, with verification on and the sha256 checked, and
refuses any member that escapes the data directory. Finding fpgen's data dir
must not import fpgen -- importing is what triggers the download -- so it reads
the module origin via find_spec without executing it. Wired into all seven CI
jobs that install pythonlib.

Pinning to model-2/2026 then failed tests/test_launch_geometry.py about 30% of
the time, on `availHeight < height`. That turned out to be our bug, not the
model's: fix_screen_no_taskbar only fired when avail equalled screen on BOTH
axes, so `availWidth < width, availHeight == height` -- a Windows taskbar
docked left or right -- passed straight through and the identity claimed no
vertical chrome at all. Rare shape, but the 2026 corpus produces it in ~30% of
draws once conditioned on a small display, against under 1% unconditioned.
Trigger on the height alone: a side dock is far rarer than a Mac menu bar, a
bottom taskbar or a Linux panel, so the vertical delta is worth more than the
few genuine side-docked machines it overwrites.

316 passed, 2 skipped with the 2026 model pinned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(fonts): host the font bundle upstream, not on a contributor's fork

scripts/data/font-bundle.json pointed at a release in JWriter20/camoufox, so
merging this would have left daijro/camoufox fetching a required build input
from a personal fork -- a build that breaks if that fork is renamed, made
private, or has the release deleted, by someone with no obligation to keep it.

The identical asset is now published at daijro/camoufox under the same
font-bundle-v1 tag, so only `repo` and `url` move here; `size` and `sha256` are
byte-for-byte what they were, which is the point -- the pin proves the bytes did
not change when the host did.

The upstream release is deliberately NOT marked latest: v152.0.4-beta.30 holds
that badge, and a build input must not displace the browser download people
actually come for. `font-bundle-*` tags are already excluded from build.yml, so
publishing it triggered no browser build.

Verified against the new host from scratch: local archive moved aside, `make
fetch-fonts` pulled 843 MB from daijro/camoufox, sha256 matched the pin, a
forced re-extract produced 1515 files, verify-fonts.py passed every check
(1513 faces stored once; 810/780/793 reachable, each inside its own groups),
and pythonlib is 316 passed, 2 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(fonts): the bundle is hosted upstream now, not on a fork

Removes the known-residue entry that said font-bundle.json points at a fork --
fb8a297 moved the asset to daijro/camoufox -- and states the rule that replaced
it, so the next person publishing a bundle does not put it back on a personal
account.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ci): pin fpgen's model for build-tester too

859000d wired scripts/pin-fpgen-model.py into every job matching
`pip install ... -e pythonlib`. build-tester does not match: it installs
pythonlib -- and so fpgen -- through build-tester/requirements.txt, which
carries `-e ../pythonlib`. So it kept fetching the model itself.

Confirmed rather than assumed: in run 36050915401 the pin logged
"OK: pinned fpgen model model-2/2026" in Patch guards and the other wired jobs,
while build-tester logged "Fetching model files from GitHub..." and no pin
output at all. That job was still reaching the network with TLS verification
off, no checksum, and no way to land on anything newer than April 2025 --
which is the job that generates the fingerprints the anti-detect suite grades,
so it is the one that least wants a different corpus than everything else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(parity): expose web content the same files stock Firefox does

Twelve chrome/resource packages are flagged `contentaccessible=yes`, and
ALLOW_CHROME in caps/nsScriptSecurityManager.cpp checks the TARGET package's
flag, never the source. So any page, from any origin, can load their files as
subresources -- and a file Camoufox adds, drops or renames there is a build tell
readable in three lines with no permission:

    const s = document.createElement('script');
    s.onload = () => BUILD_IS_NOT_STOCK;
    s.src = 'chrome://browser/content/browser-development-helpers.js';

scripts/gen-contentaccessible-manifest.py records the file set of every such
package from a stock release into scripts/data/contentaccessible-manifest.json,
and tests/patches/contentaccessible-parity.py diffs a build against it and then
actually loads a sample from a page, so the static diff cannot pass while the
real surface differs. The guard refuses to compare across Firefox versions;
re-record on an uplift.

Two existing divergences it found:

  * aboutDialog.js imported Services, declaring an extra page-visible global --
    and resource://gre/modules/Services.jsm no longer exists in Firefox 152, so
    the line threw. Services is already a chrome global; AppConstants moves to
    importESModule.
  * hide-default-browser.patch DELETED the "set as default" blocks from
    preferences/main.js. That file is contentaccessible, so a page can load it
    and read the order of the globals it declares, and deleting moved that order
    away from stock. It now hides the UI instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(parity): keep Firefox's popup blocker, give only the driver a key

`dom.disable_open_during_load` is ON in stock Firefox, so a page that calls
window.open() without user activation gets null. Playwright and geckodriver both
ship it OFF, and Camoufox inherited that. It is a one-bit tell any page can read
with no permission and nothing to wait for:

    const w = window.open('', '_blank');
    if (w) { w.close(); /* not a stock browser */ }

The pref goes back to Firefox's default. To keep
`page.evaluate(() => window.open(...))` working, popup-blocker-parity.patch adds
nsIDocShell.driverPopupsAllowed, and Runtime.js lifts the blocker for that
docShell only while a juggler Runtime.evaluate / callFunction is on the stack.
Unlike upstream's setHandlingUserInput() it grants NO user-gesture activation,
so navigator.userActivation and the autoplay policy are untouched -- trading one
tell for another would be no gain.

The window is SYNCHRONOUS on purpose. Holding it across the promise an async
evaluate returns would leave the blocker open for that evaluate's whole life,
and a page polling window.open() on a timer would eventually land inside it --
silent on stock, one popup here. So a popup opened after an `await` inside the
evaluated function is blocked, exactly as on stock without activation. The depth
is counted rather than a boolean: one docShell carries both the isolated and the
main world, and interleaved evaluates must not clear each other's flag.

tests/patches/popup-blocker-parity.py checks both directions, because either
regressing is silent -- losing the driver's key breaks ~35 upstream tests, and
losing the blocker puts the tell back.

One upstream test is skipped, with the reason recorded: it navigates to
/popup/window-open.html, which calls window.open() from the PAGE's own script at
load with no gesture. Stock returns null there, so no page event arrives.
Upstream can rely on it only because Playwright's Firefox ships the blocker off.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(package): ship glxtest and vaapitest, so graphics decisions match stock

Firefox runs two helper binaries at startup -- glxtest (GL/EGL) and vaapitest
(video decode) -- and feeds what they report into nsIGfxInfo. scripts/package.py
listed both under UNNEEDED_PATHS and dropped them to save ~50 KB. Without them
nsIGfxInfo has no data and the driver blocklist refuses EVERY WebGL context:

    WebglAllowWindowsNativeGl:false restricts context creation on this system.
    Exhausted GL driver options. (FEATURE_FAILURE_WEBGL_EXHAUSTED_DRIVERS)

Measured 2026-09-18 on one machine: stock Firefox 152.0.4 returned a full WebGL
2.0 context from the real GPU; camoufox returned null from
canvas.getContext('webgl'). The launcher hid it with webgl.force-enabled, so the
breakage only surfaced on a launch that did not go through pythonlib -- and
forcing a context is not the same decision stock makes, which is the point of
shipping the probes instead.

tests/patches/gfx-probes-packaged.py checks three things, because each can pass
while the others are broken: the packaging list must not name them, the build
must have them beside the binary, and a bare launch -- no pythonlib, so no
webgl.force-enabled -- must still get a context.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(pythonlib): derive the storage quota from the disk, default media features to stock

Two page-readable values that were constants where stock's are not.

navigator.storage.estimate().quota is not a fixed number: Gecko derives it from
the disk. GetTemporaryStorageLimit() (dom/quota/ActorsParent.cpp) takes
nsIFile::GetDiskCapacity() of the storage directory and halves it. camoufox.cfg
pinned dom.quotaManager.temporaryStorage.fixedLimit to 52428800, so every
Camoufox reported the same 50 MB where a real browser reports half its disk --
identical across every install, and wrong on all of them. The pref goes; the
launcher computes the limit from the host's own disk instead.

The media features are the same problem from the other end. Playwright sets
color_scheme / reduced_motion / forced_colors / contrast on every context, so a
desktop in dark mode reads `(prefers-color-scheme: light)` where stock reads
dark. STOCK_MEDIA_DEFAULTS supplies the no-preference values and
attach_stock_media_defaults wraps context creation to apply them; an explicit
color_scheme= from the caller still wins. The persistent context is created by
the launch itself, so its features come from the launch options rather than from
new_context() -- handled in both async_api and sync_api.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(prefs): stop suppressing speculative loading, and audit the rest

`<link rel=prefetch>` is page-readable: the prefetch is a real request, so it
lands in the document's own PerformanceResourceTiming entries. Measured
2026-09-18 against stock 152.0.4 on the same host -- stock reported the entry
(initiatorType "other", transferSize 200300), camoufox reported none, from three
lines of page JS with no gesture and no permission. network.prefetch-next and
the two dns.disablePrefetch overrides go back to Firefox's defaults;
dns-prefetch is not directly readable but belongs to the same surface.

BFCache stays disabled, now with the reason written down where someone will find
it rather than discovered again. It IS page-visible -- after a scripted
same-origin navigation and history.back(), stock fires pageshow with
persisted=true and does not re-run the document's scripts, while camoufox fires
persisted=false and rebuilds it (LEAKS row 106). Removing it is not a one-line
change: measured 2026-09-18, the document is then cached (SHISTORY logs
`OnPageHide persisted=1`) but the restore emits no navigation, no lifecycle event
and no execution context, so page.go_back() times out waiting for "load" and the
next evaluate throws against the stale context. Closing it needs FrameTree and
PageAgent to treat a persisted pageshow as a navigation.

scripts/pref-diff.py is the tool that makes this answerable in general.
camoufox.cfg sets ~375 prefs and nothing said which of them actually deviate
from stock. It reads what a stock Firefox exposes over Marionette and classifies
every override as REDUNDANT (dead weight), DEVIATION (the surface that matters),
NEW, or LOCKED. `--camoufox BIN` adds a live diff against a built browser, which
also catches prefs set by policies.json, by a patch, or at launch -- the ones
reading camoufox.cfg alone will miss.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(sundial): outrun the compositor, not just the clock

The pointer probe only made paced moves, and paced moves look the same on every
browser. What ls-pointer-move-rate actually judges is whether events are queued
and coalesced the way real input is, which only shows when the pointer outruns
the compositor -- so follow the paced pass with a 60-move burst that has no
pause between moves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(window): report the real outer size, and undo a startup auto-maximize

window.outerWidth/outerHeight were returned from MaskConfig unconditionally
while innerWidth/innerHeight report the real content area. Whenever the real
window ended up a different size than the drawn one, the pair contradicted
itself and pages could see innerWidth > outerWidth (and > screen.width):

- GNOME mutter auto-maximizes a new window covering >= 80% of the work area,
  and clamps one larger than it. Measured on a 1920x1080 display with a
  1853x1048 work area: a drawn 1680x1010 window reported outer 1680 with
  inner 1853; every bad reading fit the 80% rule (1707x912 = 1.557M px vs a
  1.553M threshold).
- An explicit Playwright viewport makes Juggler resize the real window while
  outer stayed fixed: headless viewport 1900x1000 on a drawn 1536x824 window
  reported inner > outer; 1280x720 reported 256 px of phantom side chrome.

The outer getters now report the real window, which browser-init.js already
resizes to the drawn size, and browser-init.js restores a maximize the WM
applies in the first 5 s and re-asserts the drawn size. Prototyped in an
omni.ja-patched beta.30: bad readings went from 3/24 launches to 0/24.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(guards): let the contentaccessible guard read an unpackaged build

The guard required browser/omni.ja and omni.ja beside the binary. CI tests the
UNPACKAGED dist/bin -- tests.yml says so where it packages the artifact ("there
is no omni.ja to rebuild there"; Juggler ships as loose files under
chrome/juggler/) -- so the guard could never run on the builds it exists to gate.
It failed the gate in run 36066726109 with "browser/omni.ja not found next to the
binary", after its four live page-load checks had already passed.

`mach build` leaves those resources as loose files at exactly the paths they
would occupy inside the jar, under dist/bin/browser/ for browser/omni.ja and
dist/bin/ for omni.ja. package_listing() now reads the jar when it is there and
walks the tree when it is not, and only fails when neither exists -- so a
packaged build and an objdir are both comparable, rather than one of them being
untestable.

Verified against the local unpackaged tree: 5148 and 8310 entries read, all 12
contentaccessible packages compared, 0 differences from the recorded stock
manifest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 01:01:56 +00:00
Jake Writer 4132dd50a6 refactor(juggler): make the input-dispatch deadlock structurally unreachable
Four deadlocks shipped between 2026-04 and 2026-09 -- exact-edge coordinates
(9270618), humanized trajectory points that bypassed the endpoint's guard
(541ffca, #225/#677), a zero-displacement move (16e5a13), and the near edge
(014cc65, #751/#752). Each was fixed by adding one more coordinate guard at
one more call site. That does not converge, for two reasons.

The trigger set is not enumerable. Whether relative y == 0 reaches the
renderer is decided by Math.round(boundingBox.top) < boundingBox.top -- a
rounding accident in the fractional height of browser chrome, which varies
with the spoofed OS. No review catches that.

And every miss costs the whole process. activateAndRun() serializes input on
a chain shared by every tab; EventWatcher.ensureEvent() waited forever. One
missing ack wedged every later input event in the process, permanently, at 0%
CPU with no diagnostic. #677 shows why review is not the answer: restoring the
humanize trajectory meant writing a bounds check, and the one written was a
copy of the pre-#225 form, reintroducing a fixed deadlock one day before it
was re-fixed.

Three changes, in order of leverage.

1. Bound the waits. EventWatcher.ensureEventWithin() gives up instead of
   waiting forever; MouseDispatch.sendAcked() uses it, drops the event and
   logs the type, coordinate and browser rect. This alone closes all four
   historical deadlocks, including on a build with no coordinate fix at all.

   The 5s deadline is sized from measurement, not intuition. Over 1000+
   dispatches: idle content thread p50 0ms / p99 1ms / max 12ms; a thread
   burning 8ms per event p50 8ms / max 12ms. But the ack is delivered FROM
   the content main thread, so a page running a 3s synchronous script delayed
   a legitimate ack by 2849ms. Block length is page-controlled and unbounded,
   and silently dropping real input on a slow page is the #752 symptom, so
   the deadline sits above the slowest legitimate ack rather than near the
   typical one.

   Bounding each ack is not enough to bound the work: a humanized curve is
   ~110 points in a single activation-chain slot. sendTrajectoryAcked()
   abandons the rest of a curve after the first undelivered point -- not a
   wall-clock budget, which would false-fire on exactly the slow pages the
   deadline exists to tolerate. activateAndRun() carries a 30s backstop for
   the other unbounded waits reachable from the same slot
   (apz-repaints-flushed, TabSwitchDone, the drag path's waits), none of
   which has failed yet.

2. One chokepoint. additions/juggler/input/MouseDispatch.js owns the
   relative-to-absolute conversion, the in-viewport predicate and the ack
   wait. PageHandler's three independent bounds checks and its raw
   jugglerSendMouseEvent/sendWheelEvent calls are gone; it now passes
   relative coordinates and never sees a bounding box. Net effect on that
   vendored file is 92 lines removed against 22 added -- a smaller diff
   against upstream juggler, since the logic moved into a file we own.
   Wheel events go through the same conversion, so a wheel at relative y == 0
   no longer scrolls the tab strip.

3. Enforcement. scripts/check-input-dispatch.py fails the build if anything
   outside the chokepoint dispatches synthesized input or does
   browser-relative coordinate arithmetic. It needs no browser build, so
   .github/workflows/lint.yml gates every pull request -- nothing was
   checking PRs before. Two exemptions, both content-process: PageAgent
   (drag events, already content-relative, no ack) and FrameTree (the ack
   producer). docs/input-dispatch.md states the invariant.

   tests/patches/mouse-boundary-sweep.py replaces hand-picked edge targets,
   which are what let each of the four through: humanize-edge-deadlock.py
   probes only the far edges, and humanize-mouse-trajectory.py pins
   os="linux" -- the one fingerprint immune to #751. It sweeps the whole
   viewport ring across every spoofed OS with humanize on and off, asserting
   each point is acked AND observed by the page. It depends on change 1 to
   run at all: without the backstop the first bad coordinate wedges the
   browser and the sweep dies there.
   tests/patches/input-ack-backstop.py covers the bounded wait itself, by
   blocking the content main thread far longer than the deadline -- a
   legitimate late ack, with no test-only hook in production code.

The sweep immediately found a fifth instance, pre-existing and unreported:
boundingBox.height is consistently 0.5 CSS px less than the innerHeight the
page reports, so the page's last row is half covered. With the box at
1920x977.5 +0+56.5, relative y == 977 -- innerHeight - 1, well inside the
viewport as far as the page is concerned -- dispatches at 1033.5, rounds to
1034, and the content ends at 1034. Deterministic, 4/4, and it deadlocks a
stock build. Fixed by clamping to the last whole pixel inside the element,
symmetric with the near-edge snap; both live in the one conversion now.

All seven patch tests pass: mouse-boundary-sweep (150 ring coordinates over
6 scenarios), near-edge-mouse-deadlock, input-ack-backstop,
humanize-edge-deadlock, humanize-mouse-trajectory, noop-mousemove-deadlock,
trusted-events.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQgHHGRXNp29jr4xQjK7iv
(cherry picked from commit 827b98d31e)
2026-09-05 14:26:37 -06:00
Jake WriterandClaude Opus 5 41ba997799 build: stage bundled fonts into unpackaged builds
`mach build` leaves dist/bin/fonts holding only TwemojiMozilla.ttf -- the font
bundles and fontconfig are staged by scripts/package.py, so they exist only in
packaged builds. Anything launching the objdir binary through the Python
wrapper therefore starts a browser with no usable content font, because the
wrapper sets FONTCONFIG_FILE to a file that is not there.

It fails confusingly: the browser chrome still has system fonts, so the only
symptom is tofu boxes in page content, and through AsyncCamoufox it surfaces as
a TargetClosedError with no indication of the cause. That cost real time while
writing the tests/patches scripts, hence the note in their docstrings.

`make stage-fonts` copies them in. Idempotent, and a no-op when nothing is
built yet. Not needed by `make run` or `make tests`, which launch the binary
directly and fall back to the system fontconfig.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 14:32:42 -06:00
neuregex c4716d2ec1 fix(build): stop dropping the MSVC CRT from Windows packages (#650)
package-windows pulls VCRUNTIME140/VCRUNTIME140_1/MSVCP140 out of the mozbuild
Visual Studio tree through a shell glob that pins one redist version
(14.38.33135) and one toolset (VC143). Windows builds cross-compile on Linux
and get their toolchain from mozbootstrap, so a different version there leaves
the glob unexpanded -- and add_includes_to_package() skipped anything that did
not exist, with no warning.

The package then ships without the CRT. camoufox.exe imports those DLLs, so on
any machine without the Visual C++ Redistributable installed the process dies
immediately and Playwright surfaces only "spawn UNKNOWN".

- glob the redist and toolset versions instead of pinning them
- treat a missing --includes entry as fatal, so an unexpanded glob fails the
  build instead of silently shipping a broken package
2026-07-30 14:32:42 -06:00
daijro fb6d475fd3 Drop Linux i686 support for future releases
Firefox 32-bit Linux Support ended in 2026. This change removes it from the workflow
https://blog.mozilla.org/futurereleases/2025/09/05/firefox-32-bit-linux-support-to-end-in-2026/
2026-07-15 14:20:46 -05:00
daijro 34760a639b Fix patcher attempting to use git after setup-minimal 2026-07-15 07:58:02 -05:00
Jake WriterandClaude Fable 5 26b94797c3 fix(stealth): consolidated stealth/juggler/build fixes (rebased onto FF152)
Consolidates the following individual fixes into one changeset, all rebased
onto the current Firefox 152 base and validated together via a full build:

- webrtc: stop real-IP leak under a proxy; sanitize getStats IP + fabricate a
  synthetic srflx when ICE produces none (TCP-proxy case).
- proxy: re-enable launch-arg proxy by disabling https_first.
- screen: make MaskConfig the single source for screen + CSS device dims so
  matchMedia(device-width) agrees with screen.width.
- stealth: spoof CSS2 system-font keyword resolution (font-system-fonts-css2).
- juggler: filepicker reliability + skip mouse-move coalescing for synthetic
  (juggler) events so input dispatch can't stall under parallel load.
- juggler: emit a single input event for insertText on form fields.
- juggler: reload about:blank via browsingContext.reload.
- locale: propagate launch-arg locale to BrowsingContext.
- build: create v150-removed dirs before copy-additions.
- stealth: BrowserForge headless / impossible-geometry tell corrections and
  speech-voice spoofing (host-voice leak fix).

Supersedes daijro/camoufox #637-#647.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 16:20:28 -06:00
Anton f342c20dd2 Version 152.0.2 Upgrade (#658)
* 152 upgrade patches

* remove unused patch

* fix juggler

* add rust cross compile to required deps
2026-07-05 23:03:19 -07:00
icepaq 4f5e4484d1 add install deps script 2026-07-05 13:28:42 -07:00
0ac611c4ad v150 with Windows Support - Python Package Being Merged Separately (#611)
* fix v150 patches

* screen related patch fixes

* fix juggler issues with 150

* Update grading.py

improved build tester scoring

* fix windows build for v150

- scripts/_mixin.py: switch moz_target from x86_64-pc-mingw32 (no longer
  supported in FF150) to x86_64-pc-windows-msvc
- additions/juggler/screencast/HeadlessWindowCapturer.h: typedef pid_t
  on XP_WIN; libwebrtc headers (video_capture.h, desktop_capturer.h)
  reference pid_t which is POSIX-only
- patches/anti-font-fingerprinting.patch: include mozilla/dom/Document.h
  in gfxTextRun.cpp; on Windows it is not transitively included so
  doc->GetInnerWindow() failed with "incomplete type 'Document'"

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* make service test use local binary

* updated ff fingerprint versions

* Update README.md

---------

Co-authored-by: Ubuntu <ubuntu@ip-172-31-15-96.us-east-2.compute.internal>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:34:05 -04:00
icepaq 5219a40849 build test fixes (#586)
* build test fixes

* fix wrong references

* Update PULL_REQUEST_TEMPLATE.md
2026-04-25 23:47:31 -04:00
Ruben VereeckenandClaude Opus 4.6 5f3c1f2c64 Allow disabling font spacing perturbation (seed=0 no-op, matching audio) (#548)
* Allow disabling font spacing perturbation (seed=0 no-op, matching audio)

The audio fingerprint manager treats seed==0 as "no perturbation", but
font spacing had a hardcoded fallback (0x6D2B79F5u) that made it always
active — even when no seed was explicitly set.

C++ change: remove the hardcoded fallback and add `if (seed != 0)` guard
around the LCG + glyph offset loop (mirrors AudioFingerprintManager).
Also removes the debug printf that fired on every ShapeText() call.

To disable font spacing from the Python API, set fonts:spacing_seed to 0
in the config/preset — same convention as audio:seed and canvas:seed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Add local channel install script for custom builds

Installs build artifacts to browsers/local/ instead of overwriting
official slots. Survives camoufox fetch. Handles permissions and
version.json automatically.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 11:06:25 -04:00
icepaq d6540b52ce Service Test and Contributing Guides (#521)
* example files

* contributing guides

* simple service test

* run tests in sync

* update pr template

* pip updates

* Update README.md

* typo fixes

* undo pip package update lol

* upgraded service test

* undo injections

* test with proxies

* auto set timezone and proxy url

* delete checks bundle

* split up service tests

* split up build tests

* rename service tests to service tester

* Update CONTRIBUTING.md

* fix entry vs exit ip

* allow alpha versions

* fix patch issues on macos

* bidirectional patch

* Add note on experimental pip package
2026-03-15 21:31:49 -04:00
icepaq 33b0e0ebcc Build improvements (#3)
* fix asets.car issue

* dist folder issue bruh
2026-01-04 23:26:32 -05:00
icepaqandNirupam Bhowmick dfa62f7070 Juggler fixes (#2)
* fix: migrate Services import to lazy loading pattern and disable dark theme to prevent XPIProvider crash

- Changed Services from ChromeUtils.defineLazyGetter to ChromeUtils.defineESModuleGetters with lazy object
- Updated all Services references to lazy.Services throughout Juggler.js
- Added re-copying of additions and settings after git reset in patch.py
- Removed juggler components.conf copy logic from patch.py (now handled by copy-additions.sh)
- Disabled dark theme preference in camoufox.cfg to

* fix: revert Services import to direct ESM import and re-enable dark theme preference

- Changed Services from lazy loading pattern back to direct ChromeUtils.importESModule()
- Removed lazy object wrapper and ChromeUtils.defineESModuleGetters usage
- Updated all lazy.Services references to Services throughout Juggler.js
- Re-enabled dark theme preference in camoufox.cfg (extensions.activeThemeID)
- Removed XPIProvider crash prevention comment as issue is resolved

* fix: remove redundant Services import as it's available globally in XPCOM component context

- Removed ChromeUtils.importESModule imports for XPCOMUtils, ComponentUtils, and Services
- Added comment explaining Services is available as a global in XPCOM component context
- Services.scriptloader.loadSubScript call continues to work with global Services reference

* fix: correct JugglerFrameChild.sys.mjs path by removing duplicate content directory

- Changed esModuleURI from 'chrome://juggler/content/content/JugglerFrameChild.sys.mjs' to 'chrome://juggler/content/JugglerFrameChild.sys.mjs'
- Removes erroneous duplicate 'content' directory in the child actor module path

* fix: add ESM module entries and correct JugglerFrameChild.jsm path in jar.mn

- Added JugglerFrameParent.sys.mjs entry to juggler.jar manifest
- Added JugglerFrameChild.sys.mjs entry to juggler.jar manifest
- Fixed JugglerFrameChild.jsm path from 'content/content/JugglerFrameChild.jsm' to 'content/JugglerFrameChild.jsm' removing duplicate content directory

* fix: remove redundant Services lazy getter as it's available globally in content process context

- Removed ChromeUtils.defineLazyGetter for Services in main.js
- Added comment explaining Services is available as a global
- Services reference continues to work with global availability in content process

---------

Co-authored-by: Nirupam Bhowmick <48842933+heydryft@users.noreply.github.com>
2026-01-04 11:40:05 -05:00
Anton 3cceb0f4f5 Revert "bring back camoufox branding"
This reverts commit 3258fca5b2.
2026-01-04 11:34:17 -05:00
Anton 3258fca5b2 bring back camoufox branding 2026-01-03 22:33:16 -05:00
Nirupam Bhowmick 7106903bb0 fix: add binary flag to patch command for line ending preservation
- Added --binary flag to preserve line endings (CRLF vs LF)
- Removed -F3 (fuzz factor) flag
- Helps with cross-platform patching where line endings may differ
2025-12-30 00:40:12 +00:00
Nirupam Bhowmick acba61d7aa fix: improve patch command flexibility with whitespace and fuzz tolerance
- Changed --fuzz=3 to -F3 for consistent short option format
- Added -l flag to ignore whitespace differences when applying patches
- Maintains --forward flag to skip already applied patches
2025-12-30 00:30:18 +00:00
Nirupam Bhowmick ee997d1518 make patching more lenient 2025-12-29 23:37:39 +00:00
Nirupam Bhowmick f7a9bf7fbd refactor: move MOZ_APP_VENDOR and MOZ_APP_PROFILE to moz.configure and fix juggler component registration
- Removed MOZ_APP_VENDOR and MOZ_APP_PROFILE from configure.sh branding file
- Added note that these must be set via imply_option() in browser/moz.configure
- Updated disable-data-reporting-at-compile-time.patch to set MOZ_APP_VENDOR="Camoufox" and MOZ_APP_PROFILE="camoufox"
- Changed juggler components.conf to use "type" instead of "constructor" for Firefox 146+ compatibility
- Added automatic
2025-12-27 20:23:41 +00:00
Nirupam Bhowmick 32122430be fix: every patch patches except 0, 1 playwright and roverfox context 2025-12-27 02:58:10 +00:00
Nirupam Bhowmick 75fb8d2f78 fix: auto-update 6 line numbers in 0-playwright.patch 2025-12-25 18:25:54 +00:00
Nirupam Bhowmick 08ecd86746 feat: add Firefox v147 patch update task list and tracking document
Add comprehensive task list documenting the Firefox upgrade from v135.0.1 to v147.0b3 (12 major versions). Includes status tracking for all 45 patches after syncing with upstream LibreWolf (Firefox 146) and Playwright repositories.

Key changes:
- 25 broken patches identified (56% failure rate)
- 20 patches applying cleanly (44% success rate)
- LibreWolf patches updated from upstream: 17 patches synced, 5 deleted, 2 auto-fixed
-
2025-12-19 05:58:30 +00:00
Nirupam Bhowmick cd77ea7bd5 feat: remove webrtc ipv6 func and update diff command in developer script 2025-08-24 01:36:16 +01:00
Nirupam Bhowmick e1a28778db feat: add WebRTC IP spoofing with per-context isolation support 2025-08-23 23:53:07 +01:00
Nirupam Bhowmick 15719fe9b1 feat: implement user context-based font spacing 2025-08-19 19:02:22 +01:00
Serhii Maltsev cc852b424a Minor dev UI improvements: add patch statuses directly to lists for improved usability 2025-02-13 17:52:53 +01:00
Serhii Maltsev 8dc1f6b039 fix broken f-string formatting and broken python make edits call 2025-02-11 12:41:05 +01:00
daijro 9f6d55f39b Extract inner tar in packager 2025-02-05 19:18:17 -06:00
daijro 2f2af937a1 Update packager to search for tar.xz 2025-02-05 12:13:30 -06:00
daijro 33085c90f3 Merge with Playwright a121f85
Merges patches with the latest commit: https://github.com/microsoft/playwright/commit/a121f85ce91b67aeb1191e2fcca0939ad5b38671
2025-01-24 18:20:07 -06:00
daijro bbe1cbe2b2 Memory benchmark scripts via podman #87 2024-11-30 21:15:43 -06:00
daijro 4f15447e04 Deprecate old launcher & locales 2024-11-21 18:51:27 -06:00
daijro 85eb40aee4 Leak fixes #90 2024-11-21 01:59:01 -06:00
daijro e126cf379c Update uBlock Origin assets & updater 2024-11-04 03:13:23 -06:00
daijro ad3b3f04fe Add extra parameters to test site
Added the following parameters to the WebGL testing site:
TIMESTAMP_EXT, GPU_DISJOINT_EXT, MAX_VIEWS_OVR
2024-10-15 05:37:59 -05:00
daijro 5bfc3ee026 Further improved WebGL spoofing beta.12
- Added ability to spoof webgl2 supported extensions
- Added ability to block parameters that aren't defined in config
- Passing null in config will block the value
- Added more parameters to the demo site
2024-10-14 20:31:58 -05:00
daijro 02bc15161a feat: WebGL fingerprint spoofing
Experimental WebGL fingerprint injection.
- Allows the ability to set all WebGL parameters, supported extension list, shader precision formats, & context attributes.
- Added a demo website under scripts/examples/webgl.html that can be used to generate Camoufox config data
2024-10-14 02:12:13 -05:00
daijro 8a9b062d05 Update test script to output debug.log 2024-09-30 00:41:47 -05:00
daijro ea84f792df Developer UI: Keep clean build files on reset 2024-09-09 18:48:36 -05:00
daijro a2cb02df8a Add config type validator
- Validates property types passed in --config.
- Types are stored in properties.json.
2024-08-18 05:14:20 -05:00
daijro a766940363 Makefile: Fix build resetting workspace when editing patch 2024-08-17 00:25:34 -05:00
daijro 1d31bad14e Merge gh-actions branch into main
main..gh-actions:
- CI/CD: Fix release permissions
- Fix macos packaging on debian
- CI/CD: Remove unwanted tools
- CI/CD: Attempt to fix OOM killing GH runner during LTO
- CI/CD: Fix glibc errors
- CI/CD: Downgrade to ubuntu-20.04
- CI/CD: Use target os matrix & fix release
- CI/CD: Remove Windows (temporarily)
- CI/CD: Move to AdityaGarg8/remove-unwanted-software@v4.1
- CI/CD: Limit CPU as well
- CI/CD: Create cgroup with 80% mem limit
- CI/CD: Revert "Remove .mozbuild caching"
- CI/CD: Expand root & swap build space
- CI/CD: Remove .mozbuild caching
- CI/CD: Check disk space after checkout
- CI/CD: Use easimon/maximize-build-space
- CI/CD: Add workflow dispatch trigger
- CI/CD: Experimental fix for clang, add swap space
- CI/CD: Save .mozbuid cache after Build
2024-08-16 07:34:54 -05:00
daijro 80a657268e Packaging & macos exec fixes
- Use "open -a" to launch Camoufox.app
- Fix fonts not copying correctly
- Find & move asset files to dist/ correctly
2024-08-14 05:22:01 -05:00