Commit Graph
176 Commits
Author SHA1 Message Date
Jake WriterandClaude Opus 5 eb5dc3bc5b chore(release): v152.0.4-beta.31, pythonlib 0.5.6
Browser: beta.31, cut from the integration merge in 35b45c1 (#756). Tagging
publishes it as a prerelease; beta.30 stays the latest stable release.

pythonlib: 0.5.6b1 -> 0.5.6. The pre-release existed because
PLAYWRIGHT_BROWSER_FLOORS pins Playwright >= 1.61 to browser beta.30, and
beta.30 was itself only a prerelease, so the floor pointed at a build the
resolver would not hand to users. beta.30 is now the latest stable release, so
the floor resolves and 0.5.6 can ship as final.

Verified on linux x86_64 before cutting:
  build-tester      all categories pass; the one varying slot is a
                    cross-profile uniqueness collision across 3 random draws
                    (macOS Screen, then Linux Screen, then macOS Canvas over
                    three runs), not a regression
  patch guards      16/16
  Playwright suite  13 failed, 1083 passed vs stock beta.30's 14/1082
  pythonlib tests   209 passed

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W2RfR387Mh1JhZ9LZvkptP
2026-09-05 19:25:50 -06:00
Jake WriterandClaude Opus 5 a80abb452a feat(patches): report a touchscreen digitizer, not a phone
navigator.maxTouchPoints could already be spoofed, but nothing moved with it,
so a spoofed digitizer contradicted itself in two places a script reads in one
line: (any-pointer: coarse) stayed false, and window.TouchEvent and
window.Touch were absent entirely.

Restore the aID branch in force-default-pointer.patch so the coarse bit joins
the *any-pointer* set, and only when maxTouchPoints > 0. The primary pointer
stays Fine|Hover: a touchscreen laptop still drives its trackpad, and
reporting (pointer: coarse) would claim a phone while the accompanying desktop
UA said otherwise. The host LookAndFeel value is still not consulted -- the
capability set must not vary with the machine the browser runs on.

Expose the touch interfaces by moving TouchEvent::PrefEnabled only, never
LegacyAPIEnabled. dom.w3c_touch_events.legacy_apis.enabled is false everywhere
but Android, so a real Windows touchscreen laptop exposes TouchEvent and Touch
while 'ontouchstart' in window is false. Matching that shape matters more than
exposing the whole touch API: a build that switches touch on wholesale is more
detectable than one that does nothing.

Rename mobile-fingerprint-spoofing.patch to touchscreen-fingerprint-spoofing
.patch, since the rationale is the ordinary Windows touchscreen laptop rather
than a phone, and carry the new TouchEvent.cpp hunk there beside the existing
Navigator.cpp one. The rename moves it after navigator-spoofing.patch in
basename order, so its Navigator.cpp hunk now lands with an offset; verified
to still apply cleanly with no rejects.

Warn at launch whenever navigator.maxTouchPoints is set, separately from the
blanket navigator warning, because the knock-on effects reach past navigator
into the CSS pointer media queries and the TouchEvent interfaces.

tests/patches/touchscreen-digitizer.py checks all 16 signals and asserts that
maxTouchPoints=0 still looks like a machine with no digitizer. It fails on a
binary built without this change (13/16) and passes on one built with it.

The reference values it carries are RECONSTRUCTED, not captured: the recording
from the Dell XPS 15 9510 was not reachable from the build host, so eight
values come from the specification and eight from Gecko's own gating logic.
Each is marked in the table. Check them against the real capture when the
reference machine is available; the capture wins.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W2RfR387Mh1JhZ9LZvkptP
2026-09-05 17:30:17 -06:00
Jake WriterandClaude Opus 5 0e1f9a816d fix(python): bound headful geometry again after the get_screen_cons flip
PR #315 corrects get_screen_cons()'s inverted guard (`headless is False` ->
`headless is True`), which is right on its own. But the call site passes
`headless or has_display(env)`, folding two separate questions into one
boolean, so with the corrected guard a headful run on a real display now
reads as headless and the display bound is skipped:

  headless=False, has_display=True  -> arg=True  -> None   (want Screen)
  headless=False, has_display=False -> arg=False -> Screen (want None)

That drops the monitor bound for every ordinary headful launch, which is
the constraint 2266f27 added for #499 -- a 1366x768 laptop goes back to
being handed a 2560x1440 fingerprint and a window drawn past the edge of
the screen.

Pass `headless` alone and gate on has_display() separately.
largest_display() already returns None when there is nothing to probe, so
the no-display case still yields None.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQgHHGRXNp29jr4xQjK7iv
2026-09-05 14:26:37 -06:00
Jake Writer 0169975638 fix(config): declare media:spoof_codecs, and guard the whole class
PR #562 added a `media:spoof_codecs` read on the C++ side --
MaskConfig::GetBool("media:spoof_codecs") in MP4Decoder and
MatroskaDecoder -- but never declared the key in settings/. Since
validate_config() drops any key it does not recognise, the documented
usage was inert:

    AsyncCamoufox(config={"media:spoof_codecs": True})
    -> "Skipping unknown patch media:spoof_codecs : True"

The key never reached the browser, so the feature could not be turned on
through the supported path at all. Declared in both properties.json and
camoucfg.jvv (bool, beside mediaDevices:enabled).

The new test is the general form rather than a check for this one key:
it scans patches/ and additions/ for MaskConfig::Get*/Has*("key") reads
and fails when a key is not declared in settings/properties.json. A
patch and its schema entry are two halves of one change, and shipping
only one half is a mistake this project has now made in both directions
-- canvas:seed (#721) and navigator.maxTouchPoints (#696) were declared
but unconsumed; this one was consumed but undeclared. Across the tree
the scan finds 63 reads against 109 declared keys, and media:spoof_codecs
was the only gap.

Note the runtime reads properties.json from the *installed browser
bundle*, not the repo, so this fix only takes effect for a build
packaged after it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQgHHGRXNp29jr4xQjK7iv
(cherry picked from commit 375b0fca4529a722220022c7993c030b83439db1)
2026-09-05 14:26:37 -06:00
Anton Poliatskii fff2c730be fix(pythonlib): derive navigator.appVersion from the preset's user agent
from_preset() set userAgent, platform and oscpu from the captured device
but never appVersion. Firefox reports appVersion as "5.0 (<OS tokens>)",
so leaving it unset let the host's own value through — and a page reading
two properties saw them disagree.

Measured on 152.0.4-beta.29, macOS host, os="linux", fingerprint_preset:

    navigator.platform    Linux x86_64
    navigator.appVersion  5.0 (Macintosh)     <- the host

The value is derived from the user agent rather than from the platform,
because 20 of the 65 bundled Linux presets carry a distro token
("X11; Ubuntu") that a platform lookup would flatten to "X11" — a smaller
mismatch than the host leaking, but the same kind. Firefox builds
appVersion from the same OS tokens as the UA, minus the architecture and
the Gecko revision, with Windows collapsed to its family name; checked
against 800 browserforge fingerprints, the derivation is exact on every
one, including Android and the Ubuntu variant.

A preset that ships its own appVersion keeps it, and a user agent the
rule cannot parse leaves the key unset rather than inventing a value.

(cherry picked from commit 759e4ab2fa)
2026-09-05 14:26:37 -06:00
luandhgt 1934b3532d Enable TLS verification for public IP lookups
public_ip() called requests.get with verify=False and wrapped it in a
context manager that silenced urllib3's InsecureRequestWarning, so the
disabled verification produced no output either.

These requests are routed through the user's proxy, which is the exact
position an attacker occupies. A forged response controls the value
public_ip() returns, and that value is used to spoof the WebRTC IP --
so the leak the function exists to prevent becomes attacker-selectable.
validate_ip() bounds this to a well-formed address, but the address is
still theirs to choose.

Set verify=True and drop the warning suppression. requests raises
SSLError, a subclass of RequestException, which the existing loop
already catches -- a host with a bad certificate is now skipped in
favour of the next one in URLS instead of being trusted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 9de97513b0)
2026-09-05 14:26:37 -06:00
luandhgt b2d842177a Verify sha256 of downloaded release assets before extracting
check_asset() already reads the asset's digest from the GitHub API and
stores it as installed_sha256, and AvailableVersion carries a sha256
field through to version.json. Nothing compared either against the
bytes that were downloaded: every sha256 equality check in the package
compares metadata to metadata when selecting an installed version, and
hashlib appeared only in utils.py to key a config cache.

So the archive that gets extracted over the install directory, and then
chmod 755'd and executed, was accepted on transport security alone. The
digest needed to catch a substituted or truncated asset was already in
hand and unused.

Add verify_sha256() and call it between download and extraction on both
install paths -- install_versioned() for the CLI and InstallWorker for
the GUI. It hashes in 1 MiB blocks so a multi-hundred-megabyte asset
does not have to be held in memory, and rewinds the buffer afterwards
so unzip() still reads from the start.

When no digest is published the install proceeds with a warning rather
than failing: some sources publish no digest, and refusing to install
from them would be a regression, not a fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 65cda21b4f)
2026-09-05 14:26:37 -06:00
codechrl 6b8b08646d fix(addons): re-download addons with a missing manifest
maybe_download_addons() treated an addon as already downloaded whenever its
directory existed. A download that fails partway leaves an empty directory
behind, which is then trusted on every later launch, so confirm_paths()
raises InvalidAddonPath: manifest.json is missing and never recovers. Gate
the check on manifest.json presence and rmtree the partial directory on
failure. Closes #308.

(cherry picked from commit 0a8211969b)
2026-09-05 14:26:37 -06:00
Andrew Collier e36e0fe3e1 fix: Return None if headless
(cherry picked from commit bd319ec988)
2026-09-05 14:26:37 -06:00
Jake WriterandClaude Opus 5 8cb7914328 feat(python): warn when a supplied binary predates the Playwright in use
A managed install below the version floor is upgraded by pkgman, but
executable_path deliberately bypasses that -- the caller supplied the binary,
so we neither replace it nor download another. That left one pairing nothing
checked: an old build driven by Playwright >= 1.61, which sends viewport fields
the older Juggler schema rejects. The user saw a bare

    Protocol error (Browser.setDefaultViewport)

with nothing naming the cause.

Warn rather than raise, because the pairing is not always fatal. Camoufox
defaults to no_viewport when it spoofs window dimensions (sync_api), and
Playwright then never sends Browser.setDefaultViewport -- so the default path
works fine on an old build. Measured against a real beta.29 binary on
Playwright 1.62:

    default path                     WORKS
    new_context(viewport=...)        BREAKS
    new_context(no_viewport=False)   BREAKS
    new_context(viewport=..., is_mobile=False)  BREAKS

Refusing to launch would break the setups in the first row. A build with no
version.json beside it -- an unpackaged objdir build -- tells us nothing, so it
is left alone rather than nagged about.

Verified end to end: warns on the real beta.29 build under Playwright 1.62,
silent on beta.30.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 19:34:52 -06:00
Jake WriterandClaude Opus 5 b68a4fb940 chore(release): mark pythonlib 0.5.6 as a pre-release (0.5.6b1)
Pairs the library with the browser: v152.0.4-beta.30 is published as a GitHub
pre-release, so the library that requires it should be one too.

PEP 440 puts 0.5.6b1 after 0.5.5 and before 0.5.6, and pip skips pre-releases
by default -- so `pip install camoufox` still resolves 0.5.5, and only
`pip install --pre camoufox` or an explicit pin picks this up. That is what
makes the conditional browser floor safe to exercise in the wild: the users who
opt in are the ones who get moved to beta.30.

Verified against the live release: with Playwright 1.61 installed the effective
floor resolves to beta.30 and the fetcher selects the real published asset
(camoufox-152.0.4-beta.30-lin.x86_64.zip). 172 tests pass, the 3.8 vermin gate
holds, and the package builds as camoufox-0.5.6b1.

Known wrinkle, not introduced here: _parse_semver() does int("6b1"), fails, and
substitutes 0, so 0.5.6b1 parses to (0, 5, 0). The browser constraint still
resolves correctly because repos.yml's only entry is min 0.5.0 / max 1, but a
future entry gating on a patch version would silently miss a pre-release
install. Worth making that parser PEP 440-aware separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 19:10:27 -06:00
Jake WriterandClaude Opus 5 fc3392e427 fix(python): resolve the bundle from executable_path, not the managed install
get_env_vars() and _generate_fontconfig() read the bundled fontconfig and fonts
through get_path(), i.e. the managed install, even when the caller supplied
their own binary. _load_properties() already honours executable_path for
properties.json; these two did not.

Before the floor could reject anything this silently mixed one build's fonts
into another build's launch. Once the floor is live it becomes fatal: every
launch raises UnsupportedVersion while the caller is holding a perfectly good
binary, because resolving the bundle drags in the managed install and that is
what gets version-checked.

Thread executable_path through both, matching _load_properties.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 15:19:46 -06:00
Jake WriterandClaude Opus 5 b1fe7227fa fix(python): key the browser floor on Playwright instead of a flat minimum
The incompatibility is two-dimensional -- it needs both a Playwright >= 1.61
and a browser < beta.30 -- but MIN_VERSION only knows about the browser. To
stay safe a flat floor has to assume the worst Playwright, which means:

  * every 0.5.6 user re-downloads the browser, including the majority on
    <1.61 who are in no danger;
  * installs pinned to an older build lose the pin, and prerelease/alpha users
    are moved off their channel, since every alpha sorts below beta.30;
  * the library cannot run at all until the matching browser release is
    published, making the PyPI-after-release ordering load-bearing.

Key it on the resolved Playwright instead. Measured: 1.60 works on beta.29 and
beta.30; 1.61 and 1.62 fail on beta.29 and pass on beta.30.

  playwright <1.61  -> floor alpha.1  -> every install kept
  playwright >=1.61 -> floor beta.30  -> below-beta.30 installs upgraded
  version unreadable -> floor alpha.1 -> kept; a spurious forced re-download is
                                        worse than leaving a working install

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 15:08:26 -06:00
Jake WriterandClaude Opus 5 df35ae79d2 chore(release): v152.0.4-beta.30, pythonlib 0.5.6
Raises the browser floor to beta.30 because 0.5.6 permits Playwright >= 1.61,
which sends viewport isMobile/screenSize in Browser.setDefaultViewport. Only
beta.30's Protocol.js schema accepts those; on beta.29 every new_context()
fails with "Protocol error (Browser.setDefaultViewport)". Measured: 1.60 works
on both builds, 1.61 and 1.62 fail on beta.29 and pass on beta.30.

The floor means pythonlib 0.5.6 cannot run until the beta.30 release assets are
published -- it must not reach PyPI first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 15:06:48 -06:00
Jake WriterandClaude Opus 5 ce87cf7dab fix(python): report an unsatisfiable version floor instead of recursing
camoufox_path() ended in `return camoufox_path()` after a fetch. When the
newest published build is still below CONSTRAINTS.MIN_VERSION, install() is a
no-op ("already installed") and that tail recursed ~1000 times -- each
iteration firing another GitHub API call, which exhausts the unauthenticated
rate limit (60/hr) long before the RecursionError lands.

That is precisely the state a library published ahead of its browser release
puts every user in, and it is reachable now that the floor is raised. It also
hits permanently for anyone using a repos.yml source that does not carry the
required build.

Re-check after the fetch instead, and raise UnsupportedVersion naming the
required minimum.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 15:06:48 -06:00
Jake WriterandClaude Opus 5 da67775257 fix(python): reach the fetch path when the installed build is below the floor
Raising CONSTRAINTS.MIN_VERSION is how this library has always forced a browser
upgrade (beta.12 -> beta.15 -> beta.17 -> beta.18 -> beta.19); the floor only
became 'alpha.1' incidentally, in an unrelated PR. That left the branch dead,
and it had rotted: camoufox_path() probed INSTALL_DIR/version.json, which only
the pre-multiversion flat layout ever wrote. With a versioned install below the
floor it raised FileNotFoundError instead of falling through to a fetch, so
raising the floor would have crashed every existing user rather than upgrading
them.

Treat a missing root version.json as "no legacy install here" so the caller
falls through to CamoufoxFetcher().install() as intended.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 15:06:48 -06:00
Jake Writer 17f873abc8 build(deps): raise the playwright cap to <1.63 instead of removing it
The cherry-picked #742 replaced `playwright = "<1.61"` with `"*"`. The
breakage it fixes is real, but an unbounded range removes the tripwire
rather than making the browser forward-compatible: `camoufox.server`
imports `playwright._impl._driver.compute_driver_executable`, a private
API with no compat guarantee, and `additions/juggler` is a fork of one
Playwright vintage that every minor release is free to break again --
1.61 sending `viewport.isMobile` is exactly that, and it will recur.

So the cap moves up rather than away, to the tested-current 1.62.0. No
lower bound is added: this package has never carried one, and the launch
path was exercised against 1.53.0 as well as the 1.62.0 the Playwright
suite runs on.

build-tester/requirements.txt mirrors this pin by its own comment, so it
moves with it.
2026-08-29 13:05:31 -06:00
LamerLink 2b662a8fc3 Support Playwright 1.61+.
- Remove package pin to allow Playwright >=1.61.
- Adds WebSocket frame timestamps to Juggler events.
- Extends viewport and setViewportSize protocol data with screenSize,
  isMobile, and deviceScaleFactor.
- Adds WebP screenshot support, including a default quality of 100.
- Updates the protocol schemas to describe the new fields and screenshot
  format.

Playwright 1.61 sends viewport.isMobile in Browser.setDefaultViewport, which
the juggler's protocol schema rejected -- every context creation failed, so
the whole Playwright suite errored out at fixture setup rather than reporting
results. This fixes the schema rather than capping the version.

Cherry-picked from daijro/camoufox#742 (closes #653).

Co-authored-by: LamerLink <36551116+LamerLink@users.noreply.github.com>
2026-08-29 13:05:31 -06:00
Jake WriterandD Yiapanis d6a806e2b5 fix(fingerprint): keep the WebGL renderer coherent with the screen (#729)
BrowserForge picks navigator/screen; the GPU is drawn separately from
webgl_data.db weighted only by OS. Nothing ties the two together, so the
synthetic path emits pairs no real machine ships -- a discrete desktop GPU
behind a 1024x600 panel. Consistency checks (Pixelscan, Fingerprint.com) read
that as masking even though every individual value is plausible on its own.

Builds on @dyiapanis's #730, which identified the problem and the GPU-class
thresholds, with three changes:

  * Constrain the GPU to the screen rather than the screen to the GPU.
    sample_webgl_for_screen does rejection sampling, so the GPU keeps
    webgl_data.db's real OS-weighted distribution and the geometry -- already
    reconciled against the real display and the window box by
    clamp_screen_to_display / fix_screen_no_taskbar / clamp_window_dimensions
    / clamp_window_position -- is left alone.
  * Where no coherent GPU exists at all (BrowserForge still carries
    netbook-era geometry, and nothing in the pool drives a sub-1366x768
    panel), raise_screen_to_gpu_floor lifts the screen instead. It measures
    the screen-to-avail gap BEFORE mutating -- #730 computed it after
    overwriting screen.height, which turned a 1024x600 -> 1080 bump into a
    520px "taskbar", a fresh impossible-geometry tell -- and it runs BEFORE
    clamp_screen_to_display so a genuinely small monitor still wins and a
    headful window cannot be pushed back off its own display (#499).
  * No Apple-M Retina floor. Apple silicon also ships in the Mac mini and Mac
    Studio, which drive whatever external monitor is attached, so pinning it
    to 2560x1600 would reject real hardware and shrink the pool for nothing.

Measured over 300 synthetic fingerprints, incoherent GPU/screen pairs fall
from 54.3% to 0%, with avail <= screen and availHeight < height holding in
every trial. The screen floor is a no-op for the Linux and Windows pools
(0/400 draws below it) and fires on 3.5% of macOS draws, so the entropy cost
is confined to the implausible tail it exists to remove.

Co-authored-by: D Yiapanis <d@yiapanis.co>
2026-08-29 13:05:31 -06:00
Jake Writer 160c806ad1 fix(stealth): make spoofed speech voices fail closed (#731)
Firefox registers the host's speech-dispatcher / SAPI / NSSpeech voices
unless something stops it, and nsSynthVoiceRegistry only stopped it when the
explicit `voices:blockIfNotDefined` flag was set. Nothing set that flag, so
the host was suppressed only as a side effect of a non-empty spoofed list --
and the Python layer built that list inside a bare `except Exception: pass`.

Any path that left the list empty or unset therefore fell through to the host
backend. On a stock Linux box that exposes 14805 espeak-ng voices to the page
under a fingerprint claiming macOS or Windows, which both leaks the real host
OS and contradicts the rest of the profile. Reproduced on 152.0.4-beta.29:

    config                     voices exposed
    generation raises          14805  (all host speechd)
    {"voices": []}             14805  (all host speechd)
    valid list                   115  (correct)

Three changes, so the failure is closed at both layers:

  * nsSynthVoiceRegistry::AddVoice now also blocks when MaskConfig carries a
    `voices` array at all -- including an empty one, or one whose entries were
    all rejected as malformed. An empty spoofed list must mean "no voices",
    never "all of the host's". With no `voices` key the browser still behaves
    like stock Firefox, so a bare binary is unaffected.
  * launch_options pins `voices:blockIfNotDefined` (via set_into, so an
    explicit caller value still wins) and degrades a generation failure to an
    empty list rather than leaving the key unset. It also passes the spoofed
    navigator.language through, so the default voice matches the locale.
  * validate_voices rejects the shapes MaskConfig::MVoices() silently drops --
    bare "Name:lang:type" strings and half-filled objects -- before launch
    instead of letting them degrade into a host-voice leak.

Both failure paths now expose 0 voices; the normal path still exposes 115.
2026-08-28 13:21:32 -06:00
Jake Writer 15c513296d Merge branch 'main' of https://github.com/daijro/camoufox into main
Conflict: pythonlib/camoufox/utils.py — both sides fixed the fontconfig
cache dir independently (#654 here, #712 upstream). The two spellings
resolve to the same path, since pkgman's INSTALL_DIR is
platformdirs.user_cache_dir("camoufox"). Kept INSTALL_DIR so the module
has one name for that directory, dropped the now-unused platformdirs
import, and kept the comment explaining why the dir must sit outside the
read-only browser bundle.
2026-08-20 10:58:28 -06:00
daijro cd83f7fd2f Bump python library to 0.5.5 2026-08-12 06:26:10 -05:00
D Yiapanis 658d00e0c7 fix: use platformdirs for fontconfig cache directory
_generate_fontconfig hardcoded ~/.cache/camoufox/fontconfig instead
of respecting XDG_CACHE_HOME. On systems where ~/.cache is read-only
(e.g. containerized environments), this causes OSError on browser launch.

Replaced os.path.join(os.path.expanduser('~'), '.cache', 'camoufox',
'fontconfig') with os.path.join(platformdirs.user_cache_dir('camoufox'),
'fontconfig'). platformdirs is already a declared dependency and
respects XDG_CACHE_HOME on Linux, ~/Library/Caches on macOS, and
%LOCALAPPDATA% on Windows.

Closes #654
2026-08-04 17:51:15 +10:00
Jake WriterandClaude Opus 5 75ee7eb267 fix(virtdisplay): keep 1x1x24 as the headless="virtual" default
Reverts the default half of 4b20b77. That commit raised Xvfb's root window
from 1x1x24 to 1920x1080x24 for #458, on the reasoning that a 1x1 root
"breaks anything that measures the screen".

That reasoning does not hold here:

- screen.* never comes from the root window. It comes from the generated
  fingerprint, applied per context in the browser, and
  clamp_screen_to_display() is skipped outright for virtual displays (the
  `not virtual_display` guard in utils.py), so a 1x1 root cannot clamp a
  generated screen down to 1x1.
- #458's actual symptom -- blank/dark screenshots -- does not reproduce on
  152.0.4-beta.28. Measured at both geometries on the same build, same page:

    Xvfb 1x1x24        493 distinct colours, 57.5% dominant  -> renders
    Xvfb 1920x1080x24  493 distinct colours, 55.8% dominant  -> renders

  Identical. Firefox composites offscreen, so the root window size does not
  gate rendering. A full-page screenshot of example.com under 1x1x24 is
  pixel-correct.

1x1x24 is Camoufox's long-standing default and has run that way for years.
CAMOUFOX_VIRTUAL_DISPLAY_SIZE is kept as an escape hatch for anyone who does
want a real framebuffer, and still validates its input.

The Composite half of 4b20b77 was already reverted separately in 75d09a3.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 00:19:20 -06:00
Jake Writer 1e838e86d5 ensure window === self, restore test 2026-07-30 15:50:10 -06:00
Jake WriterandClaude Opus 5 fa8a93577e fix(juggler): record video headful and under a virtual display (#93)
After the screencastFrameAck/timestamp fix, recording worked headless but still
produced nothing usable anywhere else: `headless="virtual"` and plain headful
both emitted a valid .webm containing 24 pure-white frames -- Playwright's
filler for a screencast that never delivered a frame.

nsScreencastService only has a working source when the browser is headless
(HeadlessWindowCapturer). Outside headless, CreateWindowCapturer falls through
to libwebrtc's X11 window capturer, which fails three different ways:

  * no XComposite -> startVideoRecording() succeeds and then never delivers a
    frame. This is Camoufox's own Xvfb configuration, which passes
    `-extension COMPOSITE`;
  * XComposite enabled -> the browser segfaults during capture (reproduced on
    the shipped 152.0.4-beta.28 as well, so it is not specific to this branch);
  * Wayland -> nsWindow::GetNativeData(NS_NATIVE_WINDOW_WEBRTC_DEVICE_ID) is
    documented as unhandled and returns null, so the service throws
    NS_ERROR_FAILURE ("Failed to get native window id") and no capture starts.

Capture from the compositor instead when not headless, via
WindowGlobalParent.drawSnapshot() -- the same call Page.screenshot already
uses, which is why screenshots have always worked in every mode. It renders
page content directly and does not care about the windowing system.

The tick is ack-driven, mirroring nsScreencastService's kMaxFramesInFlight = 1,
so a slow consumer throttles capture rather than queueing JPEGs. Headless keeps
the native C++ capturer, which is cheaper and already correct.

Measured on the packaged Linux build, 3s recording of an animated page, frames
decoded to PNG and inspected rather than trusting file existence:

                        before                  after
  headless              100 frames, real        unchanged, real
  headless="virtual"    24 frames, all white    100 frames, real
  headful (Xvfb, X11)   24 frames, all white    99 frames, real
  headful (Wayland env) no capture at all       99 frames, real

tests/async/test_video.py passes 5/5 both headless and headful. Enabling
Composite no longer crashes either, since X11 window capture is now unused.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 14:32:42 -06:00
Jake WriterandClaude Opus 5 75d09a3dad fix(virtdisplay): stop enabling Composite by default -- it segfaults recording
9654452 enabled Xvfb's Composite extension on the theory that #93 (no video
under headless="virtual") was caused by disabling it. Measurement disproves it:

  composite off + record_video_dir -> valid .webm, 24 pure-white frames
  composite ON  + record_video_dir -> browser dies with SIGSEGV, no video
  composite ON  + no recording     -> fine

So compositing does not fix #93, and defaulting it on turns a blank recording
into a crash for anyone recording under a virtual display. The segfault
reproduces on the shipped 152.0.4-beta.28 too, so it is a pre-existing fault in
the screencast capture path rather than something this branch introduced -- but
that is exactly why it should not be reached by default.

Kept as an opt-in (CAMOUFOX_VIRTUAL_DISPLAY_COMPOSITE=1) for hosts with real
GL, where it may behave differently. The real-screen-size half of 9654452 is
unaffected and stays.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 14:32:42 -06:00
Jake WriterandClaude Opus 5 4b20b771e2 fix(virtdisplay): give headless="virtual" a real screen and Composite (#458, #93)
Two hardcoded Xvfb arguments, both verified against a live Xvfb with xdpyinfo.

#458 -- `-screen 0 1x1x24`. A 1x1 root window is not a plausible desktop: it
breaks anything that measures the screen, and it is the reason
clamp_screen_to_display() has to special-case virtual displays (a generated
fingerprint would otherwise be clamped to 1x1). Default to 1920x1080x24;
the framebuffer cost is ~8MB. Overridable per-run with
CAMOUFOX_VIRTUAL_DISPLAY_SIZE="1920x1080[x24]", which is validated and rejects
malformed values rather than passing them to Xvfb.

#93 -- `-extension COMPOSITE`. Offscreen rendering needs Composite, which is
what Playwright's video recording uses, so disabling it silently broke
record_video_dir under headless="virtual". A real X server has the extension,
so enabling it is also the more faithful default. Set
CAMOUFOX_VIRTUAL_DISPLAY_COMPOSITE=0 to restore the old behaviour.

Verified with xdpyinfo against real Xvfb instances:
  default            -> dimensions 1920x1080, Composite present
  screen="800x600x24", composite=False -> dimensions 800x600, Composite absent
  CAMOUFOX_VIRTUAL_DISPLAY_SIZE=2560x1440 -> resolves to 2560x1440x24
  CAMOUFOX_VIRTUAL_DISPLAY_SIZE=bogus     -> VirtualDisplayNotSupported

xvfb_args becomes a property so the two settings can vary per instance; the
existing VirtualDisplay(debug=...) call sites are unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 14:32:42 -06:00
Aleksandr d5d7713684 fix: add allow_addon_new_tab launch option 2026-07-30 14:32:42 -06:00
Jake WriterandClaude Opus 5 2834a463d1 test(virtdisplay): assert the real post-condition of kill()
`VirtualDisplay.kill()` reaps the Xvfb child and then clears `self.proc`, so
asserting `vd.proc.poll() is not None` afterwards raises AttributeError on
None. Two tests failed this way on main, unrelated to any of the merged PRs.

Assert `proc is None or proc.poll() is not None` -- reaped-and-cleared is the
success path, and a surviving handle must still report an exit code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 00:33:55 -06:00
Jake WriterandClaude Opus 5 c61c55f108 fix(server): reject unservable persistent-context options (#161)
PR #398 added `persistent_context` / `user_data_dir` to `launch_options()` and
emitted `_user_data_dir` in the result, on the assumption that Playwright's
`browserServerImpl` consumes it. It does not.

`launchServer()` spreads its options into `BrowserType.launch()`, which passes
`undefined` as the userDataDir and never reads `options._userDataDir` (only
`browser._userDataDirForTest` is ever assigned, after the fact). Verified
against the bundled playwright-core 1.53.1: launching a server with
`user_data_dir=/tmp/...` starts cleanly and leaves the directory empty.

Serving a persistent context is not merely unimplemented, it is outside
Playwright's server model: `launchPersistentContext` returns a BrowserContext
while `PlaywrightServer` only accepts a `preLaunchedBrowser`.

So keep #398's genuinely-correct `camel_case` fix -- it lets any underscore-
prefixed private option reach the driver -- and drop the two options that would
otherwise be accepted, validated, and silently ignored. `launch_server()` now
fails loudly and points at the in-process API instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 00:31:59 -06:00
Piero Rolando c11b920524 Fix launch_server, to be able to use persistent_conext and user_data_dir 2026-07-28 00:29:18 -06:00
Cloudymap1e e61642aaf5 fix(server): close browser when launcher exits 2026-07-28 00:29:11 -06:00
Pratyush Sharma a5afa46cfa Apply screen constraints on Windows and macOS
get_screen_cons() was gated on DISPLAY being set, which only ever happens on
Linux, so headful runs on Windows and macOS generated fingerprints with no
monitor bound at all.

Fixes #425
2026-07-28 00:29:03 -06:00
Pratyush Sharma 22c6ffbdda Probe the host monitor in CSS pixels
screeninfo makes the process per-monitor DPI aware, so it reports physical
pixels, while Firefox lays windows out in CSS pixels. At 150% Windows scaling a
1920x1080 panel is 1280x720 CSS px, so bounding the fingerprint by the physical
size lets the window open 1.5x larger than the screen.

Refs #425
2026-07-28 00:29:03 -06:00
Pratyush Sharma fbafbcf9f0 Exclude virtual displays from the display clamp
headless='virtual' reaches launch_options as headless=False with
virtual_display set (async_api rewrites it), so the headful gate fired and
clamped the fingerprint to Xvfb's 1x1 stub. fix_screen_no_taskbar then drove
availHeight to -39 and validate_config rejected the launch outright.
2026-07-28 00:29:03 -06:00
Pratyush Sharma 2266f27501 Clamp headful window geometry to the real display
get_screen_cons() bounds the generated fingerprint to the monitor, but
BrowserForge honours a Screen constraint only when its pool has a match:
FingerprintGenerator.partial_csp catches the filtering failure and deletes the
constraint unless strict=True. So a 1366x768 laptop routinely gets a 2560x1440
fingerprint with window.outerWidth 1920, and browser-init resizes the real
chrome window to it -- rendering past the edge of the monitor.

Re-apply the bound after generation instead of trusting BrowserForge with it,
and pull screenX/screenY back inside the shrunken screen.

Headful only. headless has no window to overflow, and headless='virtual' runs a
1x1 Xvfb whose "monitor" would otherwise shrink the fingerprint to 1x1.

Fixes #499
2026-07-28 00:29:03 -06:00
Jake WriterandClaude Opus 5 63860c9cab fix(python): keep the runtime fontconfig outside the browser bundle (#654)
PR #678 made the fontconfig cache XDG-aware, but `get_path('fontconfig')`
resolves inside the versioned browser install directory
(.../browsers/official/<version>-<hash>/fontconfig/), which already holds the
bundled linux/ macos/ windows/ trees and is read-only in the common
"bake the browser into the image as root, run as non-root" deployment.

Use INSTALL_DIR / 'fontconfig' instead: still XDG-aware, but outside the
bundle. This is byte-identical to the pre-#678 path when XDG_CACHE_HOME is
unset, so existing caches are reused and no migration is needed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 00:28:52 -06:00
Andrei Zhemaituk 150328cc01 Re-using get_path for correct placement of the file(s) when XDG_CACHE_HOME is set 2026-07-28 00:27:33 -06:00
Cloudymap1e a8ad6285d6 fix(linux): prepare required runtime directory 2026-07-28 00:26:37 -06:00
Cloudymap1e 9e74311572 fix(python): preserve humanize duration types 2026-07-28 00:26:37 -06:00
Cloudymap1e 3efa5e2182 fix(python): isolate per-launch environments 2026-07-28 00:26:36 -06:00
daijro 479453d4e1 Bump pythonlib to 0.5.4 2026-07-16 17:01:21 -05:00
Jake Writer 0e4151f820 fix: page-recycle hang under spoofed window dims, and the unmerged halves of #637-#647
Fixes the new_page() hang from #666, and restores the pythonlib/ + settings/
halves of #637-#647 that were dropped when those PRs were consolidated into #666
(that PR only carried patches/ + additions/, so these never actually landed).

## new_page() hangs when window.outer* is spoofed (#666)

The outer-size hijack in browser-init.patch pinned the chrome documentElement to
the spoofed size. That caps .browserStack, which caps the content viewport, so
the content window can never reach the size Juggler asks for in
updateViewportSize() -- and awaitViewportDimensions awaits exact equality with
no timeout, so it deadlocks rather than erroring. The second new_page() hung
forever and took the context with it.

The pin was never load-bearing: GetOuterWidth/GetOuterHeight already consult
MaskConfig unconditionally (fingerprint-injection.patch), so window.outerWidth is
spoofed in C++ regardless of the real chrome window size. Resizing is enough.

Measured on the official v152.0.4-beta.26 build (headless):

    config      before        after
    none        pass          pass
    inner       pass          pass
    outer       HANG          pass
    both        HANG          pass  (iw:360 ih:740 ow:360 oh:800 -- exact)

This corrects the diagnosis in #666, which blamed the inner+outer combination and
the `!(outerWidth || outerHeight)` guard. outer* ALONE is sufficient to hang, and
dropping inner* does not help, so that guard is not the culprit.

Also fixed driver-side: Playwright's implicit 1280x720 viewport is what asks for
the impossible size, so the driver now defaults to no_viewport when the config
spoofs any window dimension. That fixes the hang on already-released builds
without a rebuild. An explicit viewport=/no_viewport= from the caller wins.

## WebRTC ICE prefs (#538)

#666 merged the C++ half of the WebRTC fix but not the prefs, so the shipped
build still has no_host=true and none of the proxy_only prefs.
proxy_only_if_behind_proxy is the pref that actually stops the real-IP leak: it
prevents a UDP STUN request routing around a TCP proxy. no_host=false keeps the
stock two-candidate shape, which obfuscate_host_addresses makes leak-free.

## Also restored from the consolidation

- fix(proxy): dom.security.https_first rewrote http:// before the launch-arg
  proxy filter saw it, breaking CONNECT-only proxies (#638).
- fix(stealth): speech-voice spoofing + stop leaking host voices (#646).
- fix(stealth): clamp inner <= outer <= avail <= screen; BrowserForge can emit
  impossible geometries that leak as tells (#647).

Refs: https://github.com/daijro/camoufox/pull/666
Refs: https://github.com/daijro/camoufox/issues/538
2026-07-16 14:38:39 -06:00
daijro 6871b114f3 Merge pull request #670 from pratyush618/fix/656-playwright-160-server-launch
Fix server launch on Playwright 1.60
2026-07-15 13:38:45 -05:00
daijro 9aebec17e5 Fixes for version pinning in GUI
- Replace icons with pins rather than checkboxes
- Add a tooltip that says "Pin version"
- Fix "Active" status bubble UX issues
2026-07-15 09:41:41 -05:00
Pratyush Sharma ab20eca72d Add regression tests for camoufox server
Cover both failure modes from #656 and pin the driver entrypoint
contract, so a future Playwright reshuffle fails in CI rather than in a
user's terminal. No browser download or launch, so they run anywhere.

Refs #656
2026-07-15 18:32:05 +05:30
Pratyush Sharma 84fadb7481 Report server exit code instead of pipe error
When the node server exits early, writing its config to the dead stdin
raised BrokenPipeError (EINVAL on Windows), burying the real cause.
communicate() ignores both, so the underlying failure stays visible.

Refs #656
2026-07-15 18:31:52 +05:30
Pratyush Sharma 5bf8081aec Fix server launch on Playwright 1.60
Playwright 1.60 bundled its internals and removed the private
lib/browserServerImpl.js that launchServer.js required, so
`python -m camoufox server` died with MODULE_NOT_FOUND. Load the
driver's package entrypoint instead, which is a bundled playwright-core
and exposes launchServer as public API.

The driver path is now passed explicitly rather than inferred from
process.cwd().

Fixes #656
2026-07-15 18:31:36 +05:30
daijro 022b7f1f8e Bump pythonlib to 0.5.3 2026-07-15 07:59:40 -05:00