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:
@@ -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 }));
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user