From 51d0314c1cb0a852570598bfdf2b877c339f2755 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 18 Jun 2026 14:31:56 +0000 Subject: [PATCH] fix(mobile): detect touch devices with spoofed viewport width Tor Browser and Firefox with privacy.resistFingerprinting report a desktop-sized viewport (~980px) even on a real Android phone. This made window.matchMedia('(max-width: 767px)') return false, so isMobile was always false on Tor, leaving the editor in side-by-side (horizontal) layout instead of the expected vertical stack. Fix: add a secondary check using `(pointer: coarse) and (max-width: 1024px)`. Touch hardware is not spoofed by fingerprinting resistance, so this reliably catches phones and tablets regardless of the reported viewport width. Applied to both getInitialLayout() and the live isMobile state in main-layout.tsx. Co-Authored-By: Claude Sonnet 4.6 --- .../components/layout/main-layout.tsx | 27 ++++++++++++++----- .../js/shared/context/layout-context.tsx | 22 ++++++++++----- 2 files changed, 35 insertions(+), 14 deletions(-) diff --git a/services/web/frontend/js/features/ide-react/components/layout/main-layout.tsx b/services/web/frontend/js/features/ide-react/components/layout/main-layout.tsx index 60512672c2..40b92eb9d5 100644 --- a/services/web/frontend/js/features/ide-react/components/layout/main-layout.tsx +++ b/services/web/frontend/js/features/ide-react/components/layout/main-layout.tsx @@ -28,6 +28,16 @@ const mainEditorLayoutModalsModules: Array<{ // Bootstrap md breakpoint — below this we stack panels vertically on mobile const MOBILE_MQ = '(max-width: 767px)' +// Secondary check: browsers that spoof viewport width (e.g. Tor Browser) still +// expose `pointer: coarse` for real touch hardware. +const TOUCH_MQ = '(pointer: coarse) and (max-width: 1024px)' + +function detectMobile() { + return ( + window.matchMedia(MOBILE_MQ).matches || + window.matchMedia(TOUCH_MQ).matches + ) +} export default function MainLayout() { const [resizing, setResizing] = useState(false) @@ -42,14 +52,17 @@ export default function MainLayout() { } = usePdfPane() const { view, pdfLayout } = useLayoutContext() - const [isMobile, setIsMobile] = useState( - () => window.matchMedia(MOBILE_MQ).matches - ) + const [isMobile, setIsMobile] = useState(detectMobile) useEffect(() => { - const mq = window.matchMedia(MOBILE_MQ) - const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches) - mq.addEventListener('change', handler) - return () => mq.removeEventListener('change', handler) + const handler = () => setIsMobile(detectMobile()) + const mq1 = window.matchMedia(MOBILE_MQ) + const mq2 = window.matchMedia(TOUCH_MQ) + mq1.addEventListener('change', handler) + mq2.addEventListener('change', handler) + return () => { + mq1.removeEventListener('change', handler) + mq2.removeEventListener('change', handler) + } }, []) // verticalSplit is always vertical; sideBySide becomes vertical on mobile diff --git a/services/web/frontend/js/shared/context/layout-context.tsx b/services/web/frontend/js/shared/context/layout-context.tsx index b4ef85685f..feafcfffb9 100644 --- a/services/web/frontend/js/shared/context/layout-context.tsx +++ b/services/web/frontend/js/shared/context/layout-context.tsx @@ -86,16 +86,24 @@ function setLayoutInLocalStorage(pdfLayout: IdeLayout) { } const MOBILE_MQ = '(max-width: 767px)' +// Secondary touch check: catches browsers that spoof viewport width (e.g. Tor +// Browser's fingerprinting resistance reports ~980px on an Android phone). +// `pointer: coarse` reflects real hardware and is not spoofed. +const TOUCH_MQ = '(pointer: coarse) and (max-width: 1024px)' + +function isMobileDevice(): boolean { + return ( + window.matchMedia(MOBILE_MQ).matches || + window.matchMedia(TOUCH_MQ).matches + ) +} function getInitialLayout(): IdeLayout { const stored = localStorage.getItem('pdf.layout') - const isMobile = window.matchMedia(MOBILE_MQ).matches - // On mobile, always start in verticalSplit (editor above, PDF below). - // We must check isMobile first: a stale 'flat' in localStorage (written by - // a previous autoSave race or a desktop session) would otherwise short-circuit - // the mobile check and leave the user stuck in a flat/editor-only layout. - // The user can still collapse to flat during a session, but it does not persist. - if (isMobile) return 'verticalSplit' + // Check mobile first — must come before the stored-'flat' check so that a + // stale 'flat' (written by an old autoSave race) doesn't block the mobile + // default. isMobileDevice() also catches spoofed-viewport browsers. + if (isMobileDevice()) return 'verticalSplit' if (stored === 'flat') return 'flat' if (stored === 'split') return 'sideBySide' if (stored === 'vertical') return 'verticalSplit'