fix(juggler): never enter Responsive Design Mode, and warn about is_mobile

#798 left RDM on for isMobile, as Playwright's Juggler does. RDM matches
no real browser: Firefox for Android never runs it, and under touch
emulation it drops a mouse click's pointer events, which no device does.
Camoufox only has desktop identities, so an is_mobile context was a
desktop UA, platform, fonts and GPU with devtools' mobile mode on top.

Juggler now keeps inRDMPane off for every page, is_mobile included, and
the isMobile plumbing #798 added is gone again. viewport-no-rdm requires
is_mobile=True to keep the platform's scrollbars too.

Both launchers warn instead (warnings.yml is_mobile): on new_page() /
new_context(is_mobile=True) of a Camoufox browser, and on is_mobile
passed to launch_options() for a persistent context. has_touch,
device_scale_factor and viewport keep working without RDM.

Upstream playwright-python skips its isMobile tests on Firefox in
1.61-1.63, so no skiplist entries are needed.

On beta.31 with this Juggler in omni.ja, viewport-no-rdm passes: 12/12 px
of scrollbar with no viewport, with a viewport, and with is_mobile, and a
has_touch click fires pointerdown and pointerup. pythonlib 411 passed;
typescript 584 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jake Writer
2026-09-27 20:09:03 +00:00
co-authored by Claude Opus 5.5
parent 22a9bc581f
commit 8081061156
12 changed files with 156 additions and 20 deletions
+2
View File
@@ -17,6 +17,7 @@ from .ip import Proxy, proxy_exit_geo
from .utils import (
async_attach_vd,
attach_no_viewport_default,
attach_desktop_only_warning,
attach_stock_media_defaults,
launch_options,
STOCK_MEDIA_DEFAULTS,
@@ -173,6 +174,7 @@ async def _launch(
if no_viewport_default:
attach_no_viewport_default(browser)
attach_stock_media_defaults(browser)
attach_desktop_only_warning(browser)
return await async_attach_vd(browser, virtual_display)
+2
View File
@@ -14,6 +14,7 @@ from .fingerprints import generate_context_fingerprint
from .ip import Proxy, proxy_exit_geo
from .utils import (
attach_no_viewport_default,
attach_desktop_only_warning,
attach_stock_media_defaults,
launch_options,
STOCK_MEDIA_DEFAULTS,
@@ -148,6 +149,7 @@ def NewBrowser(
if no_viewport_default:
attach_no_viewport_default(browser)
attach_stock_media_defaults(browser)
attach_desktop_only_warning(browser)
return sync_attach_vd(browser, virtual_display)
finally:
if pid:
+24
View File
@@ -683,6 +683,27 @@ def attach_stock_media_defaults(target: Any) -> Any:
return target
def attach_desktop_only_warning(target: Any) -> Any:
"""Warn when new_page()/new_context() asks for is_mobile: Camoufox only has
desktop identities, and Juggler ignores the option (TargetRegistry.js)."""
for name in ('new_page', 'new_context'):
original = getattr(target, name, None)
if original is None:
continue
def wrap(original: Any) -> Any:
@wraps(original)
def wrapper(*args: Any, **kwargs: Any) -> Any:
if kwargs.get('is_mobile'):
LeakWarning.warn('is_mobile')
return original(*args, **kwargs)
return wrapper
setattr(target, name, wrap(original))
return target
def attach_no_viewport_default(target: Any) -> Any:
"""
Default new_page()/new_context() to no_viewport=True.
@@ -1417,6 +1438,9 @@ def launch_options(
if disable_coop:
LeakWarning.warn('disable_coop', i_know_what_im_doing)
firefox_user_prefs['browser.tabs.remote.useCrossOriginOpenerPolicy'] = False
# A persistent context takes its context options here.
if launch_options.get('is_mobile'):
LeakWarning.warn('is_mobile', i_know_what_im_doing)
# Drop values the source supplied that this identity cannot keep, before the
# WebGL pool below defers to them (a preset's own GPU pair wins over
+7
View File
@@ -41,6 +41,13 @@ instant_animations: >-
never waits on one. A page can see it: getComputedTiming() reports a duration
of 0 where stock Firefox reports the real one.
is_mobile: >-
Camoufox is built for desktops and only draws desktop identities, so is_mobile
cannot produce a believable phone and will leak: the page still sees a desktop
User-Agent, platform, fonts, GPU and screen. Camoufox ignores is_mobile rather
than put the page in Responsive Design Mode, which a page can read too.
Use has_touch, device_scale_factor and viewport for the parts you need.
block_webgl: >-
Disabling WebGL is not recommended. Many WAFs will check if WebGL is enabled.
+8
View File
@@ -191,3 +191,11 @@ def test_instant_animations_warn_that_they_are_detectable():
with pytest.warns(LeakWarning, match="getComputedTiming"):
launch(config={"instantAnimations": True}, i_know_what_im_doing=False)
def test_is_mobile_warns_that_camoufox_is_desktop_only():
"""A persistent context takes its context options at launch."""
from camoufox._warnings import LeakWarning
with pytest.warns(LeakWarning, match="built for desktops"):
launch(is_mobile=True, i_know_what_im_doing=False)
@@ -388,3 +388,32 @@ class TestStockMediaDefaults:
assert browser.kwargs["forced_colors"] == "active"
# the ones the caller left alone still follow the host
assert browser.kwargs["reduced_motion"] == "no-override"
class TestDesktopOnlyWarning:
"""Camoufox has only desktop identities and Juggler never enters Responsive
Design Mode, so is_mobile on new_page()/new_context() gets a LeakWarning."""
class FakeBrowser:
def new_page(self, **kwargs):
return kwargs
def new_context(self, **kwargs):
return kwargs
def test_is_mobile_warns(self):
from camoufox._warnings import LeakWarning
browser = utils.attach_desktop_only_warning(self.FakeBrowser())
for create in (browser.new_page, browser.new_context):
with pytest.warns(LeakWarning, match="built for desktops"):
assert create(is_mobile=True) == {"is_mobile": True}
def test_a_desktop_context_does_not(self):
import warnings
browser = utils.attach_desktop_only_warning(self.FakeBrowser())
with warnings.catch_warnings():
warnings.simplefilter("error")
browser.new_context(viewport={"width": 800, "height": 600}, is_mobile=False)
browser.new_page()