diff --git a/frontend/css/style.css b/frontend/css/style.css index f3665de..454a5d5 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -3014,3 +3014,34 @@ body.theme-light .video-card img:not(.is-loaded) { pointer-events: none; } + +/* Privacy veil -- the page goes behind a blur whenever this window isn't the + one in front. Above everything else the app can put on screen, and painted + as a backdrop filter rather than a filter on the page itself, which would + make every fixed element inside it position against the wrong box. */ +.privacy-veil { + position: fixed; + inset: 0; + z-index: 9000; + opacity: 0; + pointer-events: none; + background: rgba(10, 8, 6, 0.38); + -webkit-backdrop-filter: blur(26px) saturate(0.55); + backdrop-filter: blur(26px) saturate(0.55); + transition: opacity 200ms ease; +} + +.privacy-veil.is-on { + opacity: 1; + /* Going behind the blur is immediate -- a fade would be a window during + which the page is still readable. Coming back can take its time. */ + transition: none; + /* So the tap that brings the window back lands here and lifts the veil, + rather than pressing something on a page nobody could see. */ + pointer-events: auto; +} + +/* Nothing to blur through without backdrop-filter, so the veil covers instead. */ +@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) { + .privacy-veil { background: var(--bg-primary); } +} diff --git a/frontend/index.html b/frontend/index.html index 42410a8..b8d25d7 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -226,6 +226,8 @@ + + @@ -240,6 +242,7 @@ + diff --git a/frontend/js/main.js b/frontend/js/main.js index aeee447..5279702 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -3,6 +3,10 @@ window.App = window.App || {}; (function() { // App bootstrap: initialize storage, render UI, and load the first page. async function initApp() { + // Armed before anything is on screen: there is nothing to show behind + // a blur yet, but the window can lose focus during startup too. + App.privacy.start(); + await App.storage.ensureDefaults(); App.ui.applyTheme(); App.ui.applyPreferredQuality(); diff --git a/frontend/js/privacy.js b/frontend/js/privacy.js new file mode 100644 index 0000000..4e40dd8 --- /dev/null +++ b/frontend/js/privacy.js @@ -0,0 +1,60 @@ +window.App = window.App || {}; +App.privacy = App.privacy || {}; + +(function() { + // The moment this window stops being the one in front -- another tab, + // another window, another app -- the page goes behind a blur. What the site + // is showing is nobody else's business: not a glance over the shoulder, not + // the app switcher's snapshot, not a screen share of the tab next door. + const ON = 'is-on'; + let veil = null; + let veiled = false; + + const element = function() { + if (!veil || !veil.isConnected) veil = document.getElementById('privacy-veil'); + return veil; + }; + + const setVeiled = function(next) { + const el = element(); + if (!el || next === veiled) return; + veiled = next; + el.classList.toggle(ON, next); + }; + + const cover = function() { setVeiled(true); }; + const uncover = function() { setVeiled(false); }; + + function start() { + if (!element()) return; + + // Leaving. Any one of these can be the last event before the window is + // out of sight, so each covers on its own, and covers at once: a blur + // that lands after the app switcher took its picture is no blur at all. + window.addEventListener('blur', cover); + window.addEventListener('pagehide', cover); + document.addEventListener('visibilitychange', function() { + if (document.visibilityState === 'hidden') cover(); + else uncover(); + }); + + // Coming back. + window.addEventListener('focus', uncover); + window.addEventListener('pageshow', uncover); + + // A backstop, for whatever the browser did or didn't report: someone + // using the page is someone looking at it. The first touch lands on the + // veil and lifts it instead of reaching the thing underneath, which is + // the right way round for a tap that was meant for a page you could see. + ['pointerdown', 'touchstart', 'keydown', 'wheel'].forEach(function(name) { + window.addEventListener(name, uncover, { capture: true, passive: true }); + }); + } + + App.privacy = { + start: start, + cover: cover, + uncover: uncover, + isVeiled: function() { return veiled; } + }; +})(); diff --git a/tests/smoke_privacy.py b/tests/smoke_privacy.py new file mode 100644 index 0000000..9faf81b --- /dev/null +++ b/tests/smoke_privacy.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +"""The privacy veil: the page blurs whenever this window is not the one in front. + +Run against a locally running backend: + + backend/main.py & + .venv/bin/python tests/smoke_privacy.py + +Headless Chromium keeps every tab visible and focused -- opening a second tab +and bringing it to the front changes neither, and neither does freezing the +page over CDP -- so there is no way to genuinely send this window to the back. +The transitions are therefore dispatched by hand, which tests what this change +owns -- what the page does when the browser says it has gone away -- and not +what the browser does with a backgrounded tab. +""" +import sys +from playwright.sync_api import sync_playwright + +BASE = "http://127.0.0.1:5000/" + +VEILED = """() => { + const veil = document.getElementById('privacy-veil'); + return !!veil && veil.classList.contains('is-on'); +}""" + +OPACITY = """() => getComputedStyle(document.getElementById('privacy-veil')).opacity""" + +# What a tap in the middle of the window would land on. +UNDER_FINGER = """() => { + const el = document.elementFromPoint(innerWidth / 2, innerHeight / 2); + return el ? el.id || el.className || el.tagName : null; +}""" + +# Neither a second tab nor CDP can put this window in the background, so the +# two halves of a tab switch are announced the way the browser would announce +# them. +HIDE = """() => { + Object.defineProperty(document, 'visibilityState', + { configurable: true, get: () => 'hidden' }); + document.dispatchEvent(new Event('visibilitychange')); +}""" + +SHOW = """() => { + Object.defineProperty(document, 'visibilityState', + { configurable: true, get: () => 'visible' }); + document.dispatchEvent(new Event('visibilitychange')); +}""" + +# The highest z-index anywhere in the app's own stylesheet, veil excluded: the +# veil has to beat the player and the reels feed, not just the page behind them. +HIGHEST_OTHER = """() => { + let top = 0; + for (const sheet of document.styleSheets) { + let rules; + try { rules = sheet.cssRules; } catch (err) { continue; } + for (const rule of rules || []) { + if (!rule.selectorText || rule.selectorText.includes('privacy-veil')) continue; + const z = parseInt(rule.style && rule.style.zIndex, 10); + if (!isNaN(z) && z > top) top = z; + } + } + return top; +}""" + + +class Checks: + def __init__(self): + self.failed = 0 + + def ok(self, label, condition, detail=""): + mark = "PASS" if condition else "FAIL" + if not condition: + self.failed += 1 + print(f" [{mark}] {label}" + (f" -- {detail}" if detail and not condition else "")) + + +def main(): + c = Checks() + with sync_playwright() as p: + browser = p.chromium.launch(args=[ + "--no-sandbox", + "--disable-dev-shm-usage", + "--disable-gpu", + "--renderer-process-limit=1", + "--js-flags=--max-old-space-size=512", + ]) + page = browser.new_page(viewport={"width": 430, "height": 930}) + crashes = [] + page.on("pageerror", lambda e: crashes.append(str(e))) + + page.goto(BASE, wait_until="load") + page.wait_for_function("() => window.App && App.privacy && App.privacy.isVeiled", timeout=30000) + page.wait_for_timeout(300) + + print("\nthe window in front") + c.ok("nothing is veiled", page.evaluate(VEILED) is False) + c.ok("and the veil is out of the way", page.evaluate(OPACITY) == "0") + c.ok("so taps reach the page", page.evaluate(UNDER_FINGER) != "privacy-veil", + str(page.evaluate(UNDER_FINGER))) + + print("\nthe tab going to the back") + page.evaluate(HIDE) + c.ok("the page goes behind the blur", page.evaluate(VEILED) is True) + c.ok("fully, and at once", page.evaluate(OPACITY) == "1") + c.ok("with nothing underneath reachable", page.evaluate(UNDER_FINGER) == "privacy-veil", + str(page.evaluate(UNDER_FINGER))) + c.ok("and it sits above everything the app can show", + page.evaluate("() => parseInt(getComputedStyle(document.getElementById('privacy-veil')).zIndex, 10)") + > page.evaluate(HIGHEST_OTHER), + str(page.evaluate(HIGHEST_OTHER))) + + print("\ncoming back to it") + page.evaluate(SHOW) + page.wait_for_timeout(300) + c.ok("the blur lifts", page.evaluate(VEILED) is False) + + print("\nlosing focus without leaving the tab -- another window, say") + page.evaluate("() => window.dispatchEvent(new Event('blur'))") + c.ok("the page goes behind the blur too", page.evaluate(VEILED) is True) + page.evaluate("() => window.dispatchEvent(new Event('focus'))") + c.ok("and comes back when focus does", page.evaluate(VEILED) is False) + + print("\na touch on the veil") + page.evaluate("() => window.dispatchEvent(new Event('blur'))") + c.ok("veiled to start with", page.evaluate(VEILED) is True) + page.evaluate("""() => document.getElementById('privacy-veil') + .dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))""") + c.ok("lifts it rather than pressing what is underneath", page.evaluate(VEILED) is False) + + print("\nthe tab going away altogether") + page.evaluate("() => window.dispatchEvent(new Event('pagehide'))") + c.ok("leaves it blurred behind us", page.evaluate(VEILED) is True) + + c.ok("nothing threw along the way", not crashes, str(crashes[:2])) + browser.close() + + print(f"\n{'OK' if not c.failed else 'FAILED'}: {c.failed} check(s) failed") + return 1 if c.failed else 0 + + +sys.exit(main())