diff --git a/additions/juggler/TargetRegistry.js b/additions/juggler/TargetRegistry.js index 20a83a0..70b1216 100644 --- a/additions/juggler/TargetRegistry.js +++ b/additions/juggler/TargetRegistry.js @@ -513,7 +513,6 @@ export class PageTarget { this._linkedBrowser = tab.linkedBrowser; this._browserContext = browserContext; this._viewportSize = undefined; - this._isMobile = undefined; this._zoom = 1; this._initialDPPX = this._linkedBrowser.browsingContext.overrideDPPX; this._url = 'about:blank'; @@ -702,10 +701,16 @@ export class PageTarget { } updateMobileEmulation(browsingContext = undefined) { - // Responsive Design Mode is devtools' mobile mode (overlay scrollbars, and - // RDM branches in screen, window and navigator getters), all readable by - // the page. As in Playwright's Juggler, only isMobile asks for it. - (browsingContext || this._linkedBrowser.browsingContext).inRDMPane = !!(this._isMobile ?? this._browserContext.isMobile); + // Responsive Design Mode is devtools' mobile mode: overlay scrollbars, a + // mouse click's pointer events dropped under touch emulation, and RDM + // branches in screen, window and navigator getters, all readable by the + // page. It matches no real browser -- Firefox for Android never runs it -- + // and Camoufox has only desktop identities, so it stays off even for + // isMobile. Playwright's Juggler turns it on for isMobile + // (microsoft/playwright#41859); this is a deliberate difference, and the + // launchers warn when isMobile is passed. The viewport does not need RDM: + // the element's size sets it. + (browsingContext || this._linkedBrowser.browsingContext).inRDMPane = false; } async updateZoom(browsingContext = undefined) { @@ -831,9 +836,8 @@ export class PageTarget { await this._channel.connect('').send('setInterceptFileChooserDialog', enabled).catch(e => {}); } - async setViewportSize(viewportSize, isMobile) { + async setViewportSize(viewportSize) { this._viewportSize = viewportSize; - this._isMobile = isMobile; await this.updateViewportSize(); } @@ -1253,7 +1257,6 @@ class BrowserContext { this.downloadOptions = undefined; this.defaultViewportSize = undefined; this.deviceScaleFactor = undefined; - this.isMobile = undefined; this.defaultUserAgent = null; this.defaultPlatform = null; this.touchOverride = false; @@ -1379,7 +1382,6 @@ class BrowserContext { async setDefaultViewport(viewport) { this.defaultViewportSize = viewport ? viewport.viewportSize : undefined; this.deviceScaleFactor = viewport ? viewport.deviceScaleFactor : undefined; - this.isMobile = viewport ? viewport.isMobile : undefined; await Promise.all(Array.from(this.pages).map(page => page.updateViewportSize())); } diff --git a/additions/juggler/protocol/PageHandler.js b/additions/juggler/protocol/PageHandler.js index 894ad0c..7743205 100644 --- a/additions/juggler/protocol/PageHandler.js +++ b/additions/juggler/protocol/PageHandler.js @@ -241,8 +241,8 @@ export class PageHandler { }); } - async ['Page.setViewportSize']({viewportSize, isMobile}) { - await this._pageTarget.setViewportSize(viewportSize === null ? undefined : viewportSize, isMobile); + async ['Page.setViewportSize']({viewportSize}) { + await this._pageTarget.setViewportSize(viewportSize === null ? undefined : viewportSize); } async ['Page.setZoom']({zoom}) { diff --git a/pythonlib/camoufox/async_api.py b/pythonlib/camoufox/async_api.py index 52cb498..dcb35a2 100644 --- a/pythonlib/camoufox/async_api.py +++ b/pythonlib/camoufox/async_api.py @@ -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) diff --git a/pythonlib/camoufox/sync_api.py b/pythonlib/camoufox/sync_api.py index 5cd65d7..ea82635 100644 --- a/pythonlib/camoufox/sync_api.py +++ b/pythonlib/camoufox/sync_api.py @@ -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: diff --git a/pythonlib/camoufox/utils.py b/pythonlib/camoufox/utils.py index c0889da..b3e7f9f 100644 --- a/pythonlib/camoufox/utils.py +++ b/pythonlib/camoufox/utils.py @@ -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 diff --git a/pythonlib/camoufox/warnings.yml b/pythonlib/camoufox/warnings.yml index da49b9a..91d9e73 100644 --- a/pythonlib/camoufox/warnings.yml +++ b/pythonlib/camoufox/warnings.yml @@ -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. diff --git a/pythonlib/tests/test_identity_salt.py b/pythonlib/tests/test_identity_salt.py index 2e2d9e4..57fb5f2 100644 --- a/pythonlib/tests/test_identity_salt.py +++ b/pythonlib/tests/test_identity_salt.py @@ -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) diff --git a/pythonlib/tests/test_launch_environment.py b/pythonlib/tests/test_launch_environment.py index 02cceef..db393b2 100644 --- a/pythonlib/tests/test_launch_environment.py +++ b/pythonlib/tests/test_launch_environment.py @@ -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() diff --git a/tests/patches/viewport-no-rdm.py b/tests/patches/viewport-no-rdm.py index 99d2ba3..b3e4b49 100644 --- a/tests/patches/viewport-no-rdm.py +++ b/tests/patches/viewport-no-rdm.py @@ -10,14 +10,15 @@ scrollbars pinned, a desktop identity measured 12 px of scrollbar on a launch-level page and 0 px in any context with a viewport. Playwright's Juggler now enables RDM only for `isMobile` -(microsoft/playwright#41859), and so does Camoufox's. The guard pins classic -scrollbars and measures the scrollbar gutter three ways: +(microsoft/playwright#41859). Camoufox's never does: RDM matches no real +browser (Firefox for Android does not run it), and Camoufox only has desktop +identities, so the launchers warn about is_mobile instead. The guard pins +classic scrollbars and measures the scrollbar gutter three ways: - a launch-level page, the control: it must be non-zero, or the check is vacuous; - a context with a viewport, which must match the control; - - a context with a viewport and is_mobile=True, which must still get RDM's - overlay scrollbars, so the option the caller asked for is not dropped. + - a context with a viewport and is_mobile=True, which must match it too. RDM plus touch also swallowed the pointer events of a mouse click (PointerEventHandler): in a desktop context with has_touch=True, page.click() @@ -29,6 +30,7 @@ requires the same event sequence as on the launch-level page. """ import sys +import warnings from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) @@ -68,6 +70,7 @@ def click_events(page) -> str: def main() -> int: + from camoufox._warnings import LeakWarning from camoufox.sync_api import Camoufox binary = resolve_binary() @@ -76,7 +79,9 @@ def main() -> int: i_know_what_im_doing=True) as browser: control = measure(browser.new_page(no_viewport=True)) emulated = measure(browser.new_context(viewport=VIEWPORT).new_page()) - mobile = measure(browser.new_context(viewport=VIEWPORT, is_mobile=True).new_page()) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", LeakWarning) # is_mobile warns; that is the point + mobile = measure(browser.new_context(viewport=VIEWPORT, is_mobile=True).new_page()) control_click = click_events(browser.new_page(no_viewport=True)) touch_click = click_events(browser.new_context(viewport=VIEWPORT, has_touch=True).new_page()) @@ -95,10 +100,10 @@ def main() -> int: failures.append( f"scrollbar width differs with a viewport: {emulated['page']}/{emulated['box']} px " f"vs {control['page']}/{control['box']} px without (Responsive Design Mode)") - if (mobile["page"], mobile["box"]) != (0, 0): + if (mobile["page"], mobile["box"]) != (control["page"], control["box"]): failures.append( - f"is_mobile=True did not enable Responsive Design Mode: scrollbars measure " - f"{mobile['page']}/{mobile['box']} px, not RDM's 0/0 overlay") + f"scrollbar width differs with is_mobile=True: {mobile['page']}/{mobile['box']} px " + f"vs {control['page']}/{control['box']} px without (Responsive Design Mode)") if "pointerdown" not in control_click: failures.append(f"a click on the launch page fired no pointerdown ({control_click!r}): the check is vacuous") if touch_click != control_click: @@ -110,7 +115,7 @@ def main() -> int: print(f"FAIL: {f}") if failures: return 1 - print("PASS: a viewport keeps the platform's scrollbars; only is_mobile enables Responsive Design Mode, and a click keeps its pointer events.") + print("PASS: no context enters Responsive Design Mode -- a viewport keeps the platform's scrollbars, is_mobile included, and a click keeps its pointer events.") return 0 diff --git a/typescript/src/sync_api.ts b/typescript/src/sync_api.ts index 1df12a2..b1cd45a 100644 --- a/typescript/src/sync_api.ts +++ b/typescript/src/sync_api.ts @@ -20,6 +20,7 @@ import { ensureModel } from "./fpgen/index.js"; import { type ProxyConfig, ProxyHelper, proxyExitGeo } from "./ip.js"; import { applyNoViewport, + attachDesktopOnlyWarning, attachNoViewportDefault, attachStockMediaDefaults, attachVirtualDisplay, @@ -193,6 +194,7 @@ async function launchWith( attachNoViewportDefault(browser); } attachStockMediaDefaults(browser); + attachDesktopOnlyWarning(browser); return attachVirtualDisplay(browser, virtualDisplay); } diff --git a/typescript/src/utils.ts b/typescript/src/utils.ts index 443b713..7c5a42f 100644 --- a/typescript/src/utils.ts +++ b/typescript/src/utils.ts @@ -990,6 +990,23 @@ export function attachStockMediaDefaults(target: T): T { return target; } +/** + * Warn when newPage()/newContext() asks for isMobile: Camoufox only has desktop + * identities, and Juggler ignores the option (TargetRegistry.js). + */ +export function attachDesktopOnlyWarning(target: T): T { + for (const name of ["newPage", "newContext"] as const) { + const original = (target as any)[name]; + if (typeof original !== "function") continue; + (target as any)[name] = (options?: Record, ...rest: any[]) => { + if (options?.isMobile || options?.is_mobile) + LeakWarning.warn("is_mobile"); + return original.call(target, options, ...rest); + }; + } + return target; +} + /** * Normalise a context-options object onto the JS API's `viewport: null`, * defaulting to it when the caller expressed no preference. Playwright-Python @@ -1679,6 +1696,10 @@ export async function launchOptions({ firefox_user_prefs["browser.tabs.remote.useCrossOriginOpenerPolicy"] = false; } + // A persistent context takes its context options here. + if (passthrough.is_mobile || passthrough.isMobile) { + 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. Here, not earlier, because the check diff --git a/typescript/tests/launch.test.ts b/typescript/tests/launch.test.ts index 7fa7bdb..b0f9e18 100644 --- a/typescript/tests/launch.test.ts +++ b/typescript/tests/launch.test.ts @@ -384,6 +384,26 @@ describe("test_launch_environment: stock media defaults", () => { expect(opts.forcedColors).toBe("active"); expect(opts.reducedMotion).toBeNull(); }); + + it("isMobile warns that Camoufox is desktop-only", async () => { + const browser = utils.attachDesktopOnlyWarning(new FakeBrowser()); + for (const create of ["newPage", "newContext"] as const) { + const { warnings: caught } = await warnings.recordWarnings(async () => + browser[create]({ isMobile: true }), + ); + expect( + caught.some( + (w) => + w.category === "LeakWarning" && + w.message.includes("built for desktops"), + ), + ).toBe(true); + } + const { warnings: none } = await warnings.recordWarnings(async () => + browser.newContext({ isMobile: false }), + ); + expect(none).toEqual([]); + }); }); describe("test_viewport_default", () => { @@ -886,6 +906,20 @@ describe.skipIf(!modelReady)( expect(warned).toBe(true); }); + it("is_mobile warns that Camoufox is desktop-only", async () => { + const { warned } = await launchWarning( + { + os: "windows", + executable_path: BUNDLE_EXE, + is_mobile: true, + i_know_what_im_doing: false, + }, + "LeakWarning", + "built for desktops", + ); + expect(warned).toBe(true); + }); + it("keeps the caller's seeds", async () => { const config = await launchConfig({ os: "linux",