diff --git a/frontend/src/hooks/__tests__/useDevToolsProtection.test.tsx b/frontend/src/hooks/__tests__/useDevToolsProtection.test.tsx index 137eafec..0e16c6c1 100644 --- a/frontend/src/hooks/__tests__/useDevToolsProtection.test.tsx +++ b/frontend/src/hooks/__tests__/useDevToolsProtection.test.tsx @@ -147,6 +147,66 @@ describe('useDevToolsProtection', () => { expect(onDevToolsDetected).toHaveBeenCalledTimes(1); }); + describe('viewport heuristic', () => { + const setWindowMetrics = (metrics: { + innerHeight: number; outerHeight: number; innerWidth: number; outerWidth: number; dpr: number; + }) => { + for (const [key, value] of Object.entries({ + innerHeight: metrics.innerHeight, + outerHeight: metrics.outerHeight, + innerWidth: metrics.innerWidth, + outerWidth: metrics.outerWidth, + devicePixelRatio: metrics.dpr, + })) { + Object.defineProperty(window, key, { configurable: true, writable: true, value }); + } + }; + + it('does not fire on mount for a window that is merely zoomed', () => { + // 200% zoom on a 1000px-tall window: innerHeight halves, outerHeight + // does not, so the absolute gap is ~580px — far past every threshold. + setWindowMetrics({ innerHeight: 500, outerHeight: 1080, innerWidth: 900, outerWidth: 1000, dpr: 2 }); + const onDevToolsDetected = vi.fn(); + renderHook(() => useDevToolsProtection({ enabled: true, onDevToolsDetected, detectionSensitivity: 'high' })); + window.dispatchEvent(new Event('resize')); + + expect(onDevToolsDetected).not.toHaveBeenCalled(); + }); + + it('does not fire when the gap grows because the guest zoomed in', () => { + setWindowMetrics({ innerHeight: 900, outerHeight: 1000, innerWidth: 1000, outerWidth: 1000, dpr: 1 }); + const onDevToolsDetected = vi.fn(); + renderHook(() => useDevToolsProtection({ enabled: true, onDevToolsDetected, detectionSensitivity: 'high' })); + + setWindowMetrics({ innerHeight: 450, outerHeight: 1000, innerWidth: 500, outerWidth: 1000, dpr: 2 }); + window.dispatchEvent(new Event('resize')); + + expect(onDevToolsDetected).not.toHaveBeenCalled(); + }); + + it('fires when the gap grows at a constant pixel ratio (a docked panel)', () => { + setWindowMetrics({ innerHeight: 900, outerHeight: 1000, innerWidth: 1000, outerWidth: 1000, dpr: 1 }); + const onDevToolsDetected = vi.fn(); + renderHook(() => useDevToolsProtection({ enabled: true, onDevToolsDetected, detectionSensitivity: 'medium' })); + + setWindowMetrics({ innerHeight: 600, outerHeight: 1000, innerWidth: 1000, outerWidth: 1000, dpr: 1 }); + window.dispatchEvent(new Event('resize')); + + expect(onDevToolsDetected).toHaveBeenCalledTimes(1); + }); + + it('ignores a plain window resize, where outer and inner move together', () => { + setWindowMetrics({ innerHeight: 900, outerHeight: 1000, innerWidth: 1000, outerWidth: 1000, dpr: 1 }); + const onDevToolsDetected = vi.fn(); + renderHook(() => useDevToolsProtection({ enabled: true, onDevToolsDetected, detectionSensitivity: 'high' })); + + setWindowMetrics({ innerHeight: 500, outerHeight: 600, innerWidth: 700, outerWidth: 700, dpr: 1 }); + window.dispatchEvent(new Event('resize')); + + expect(onDevToolsDetected).not.toHaveBeenCalled(); + }); + }); + it('lets ordinary keystrokes through', () => { renderHook(() => useDevToolsProtection({ enabled: true })); diff --git a/frontend/src/hooks/useDevToolsProtection.ts b/frontend/src/hooks/useDevToolsProtection.ts index 70ff8a16..d9f490ff 100644 --- a/frontend/src/hooks/useDevToolsProtection.ts +++ b/frontend/src/hooks/useDevToolsProtection.ts @@ -61,9 +61,30 @@ export const useDevToolsProtection = (options: UseDevToolsProtectionOptions) => const threshold = detectionSensitivity === 'high' ? 160 : detectionSensitivity === 'low' ? 260 : 200; + // The outer/inner gap is measured RELATIVE to a baseline taken when the + // hook mounts, not as an absolute number. innerHeight is in page CSS px + // (it shrinks under browser zoom) while outerHeight is not, so at 150-200% + // zoom the absolute gap on a normal window is 400-500px — an + // accessibility zoom looked exactly like a docked panel and, at + // protectionLevel "maximum", bounced the guest off the gallery on load. + // A zoom step changes devicePixelRatio; docking DevTools does not. So the + // baseline is re-taken whenever the ratio changes, and only a gap that + // grows past the threshold at a constant ratio counts as DevTools. + const measure = () => ({ + dpr: window.devicePixelRatio || 1, + height: window.outerHeight - window.innerHeight, + width: window.outerWidth - window.innerWidth, + }); + let baseline = measure(); + const detectByWindowSize = () => { - if (window.outerHeight - window.innerHeight > threshold || - window.outerWidth - window.innerWidth > threshold) { + const now = measure(); + if (now.dpr !== baseline.dpr) { + baseline = now; + return; + } + if (now.height - baseline.height > threshold || + now.width - baseline.width > threshold) { handleDevToolsDetected(); } }; @@ -97,10 +118,11 @@ export const useDevToolsProtection = (options: UseDevToolsProtectionOptions) => document.addEventListener('contextmenu', handleImageContextMenu); document.addEventListener('keydown', handleKeyDown, true); - // Docking/undocking DevTools resizes the viewport; the initial call covers - // the case where it was already open when the gallery loaded. + // Docking/undocking DevTools resizes the viewport. There is deliberately + // no check at mount: a panel that was already open when the gallery + // loaded is indistinguishable from a zoomed window at that point, and + // the shortcut-key deterrent above still applies. window.addEventListener('resize', detectByWindowSize); - detectByWindowSize(); return () => { document.removeEventListener('contextmenu', handleImageContextMenu);