fix(gallery): stop browser zoom tripping the devtools viewport heuristic

innerHeight is in page CSS pixels and shrinks under browser zoom;
outerHeight does not. At 150-200% zoom a normal window therefore shows an
absolute outer/inner gap of 400-500px, past every threshold, so an
accessibility zoom read as a docked DevTools panel and - at protectionLevel
"maximum" - redirected the guest off the gallery on load. Pre-existing
(the previous threshold was 100px), but the rewrite kept the shape.

The gap is now measured relative to a baseline taken at mount, and the
baseline is re-taken whenever devicePixelRatio changes, which a zoom step
does and a docked panel does not. Only a gap that grows past the threshold
at a constant ratio counts. The mount-time check is dropped: a panel that
is already open at load is indistinguishable from a zoomed window.
This commit is contained in:
Paul Nothaft
2026-09-02 09:33:34 +02:00
parent 77b11ab874
commit 72894e22c2
2 changed files with 87 additions and 5 deletions
@@ -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 }));
+27 -5
View File
@@ -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);