Protect selected windows from screen capture

Add capture privacy rules for windows, transient children, overview previews and client cursors, with separate capture rendering and deferred-frame invalidation. Keep detached close snapshots out of captures after clients leave the registry.

Validated with release workspace tests, private compositor/EGL regressions, and an isolated release PipeWire capture test.
This commit is contained in:
Samuel Onoja authored and GitHub committed 2026-10-03 10:49:01 +01:00
1 parent bffd538dd4
commit 453ec16583
24 files changed
+1132 -41

No files matched your search

+35 -5
View File
@@ -3,6 +3,7 @@
FERESE_TEST_WINDOW_CAPTURE=1 python3 scripts/tests/test_window_capture_isolated.py
Requires built debug binaries, a Wayland host, PipeWire, gst-launch-1.0,
cc, wayland-scanner and Pillow.
Override FERESE_TEST_BINARY, FERESE_TEST_CTL and FERESE_TEST_PORTAL to test a release build.
"""
import io
import json
@@ -16,6 +17,9 @@ import time
import unittest
REPO = Path(__file__).resolve().parents[2]
COMPOSITOR = Path(os.environ.get("FERESE_TEST_BINARY", REPO / "target/debug/ferese"))
CTL = Path(os.environ.get("FERESE_TEST_CTL", REPO / "target/debug/feresectl"))
PORTAL = Path(os.environ.get("FERESE_TEST_PORTAL", REPO / "target/debug/xdg-desktop-portal-ferese"))
@unittest.skipUnless(os.environ.get("FERESE_TEST_WINDOW_CAPTURE") == "1", "requires a Wayland host")
@@ -54,7 +58,7 @@ class WindowCapture(unittest.TestCase):
env.pop("FERESE_SHELL_CONTROL_SOCKET", None)
log = (root / "compositor.log").open("w")
try:
compositor = subprocess.Popen([str(REPO / "target/debug/ferese"), "--backend=nested"], env=env, stdout=log, stderr=log, start_new_session=True)
compositor = subprocess.Popen([str(COMPOSITOR), "--backend=nested"], env=env, stdout=log, stderr=log, start_new_session=True)
processes.append(compositor)
deadline = time.monotonic() + 10
while not (runtime / "ferese/control.sock").exists():
@@ -65,7 +69,7 @@ class WindowCapture(unittest.TestCase):
env["WAYLAND_DISPLAY"] = str(next(path for path in sockets if not path.name.endswith(".lock")))
def call(*args):
return subprocess.check_output([str(REPO / "target/debug/feresectl"), *args], env=env, timeout=10)
return subprocess.check_output([str(CTL), *args], env=env, timeout=10)
def windows(count):
deadline = time.monotonic() + 10
@@ -94,7 +98,7 @@ class WindowCapture(unittest.TestCase):
# Exercise the asynchronous conversion worker and reuse its render
# target across a stream, not just the synchronous screenshot path.
stream_env = dict(env, PIPEWIRE_REMOTE=str(Path(os.environ["XDG_RUNTIME_DIR"]) / "pipewire-0"))
worker = subprocess.Popen([str(REPO / "target/debug/xdg-desktop-portal-ferese"),
worker = subprocess.Popen([str(PORTAL),
"--stream-window", str(target["id"]), "hidden"],
env=stream_env, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=log, start_new_session=True)
@@ -105,7 +109,7 @@ class WindowCapture(unittest.TestCase):
ready = json.loads(worker.stdout.readline())
self.assertEqual((ready["width"], ready["height"]), (round(640 * scale), round(480 * scale)))
raw = root / "frames.bgrx"
subprocess.run(["gst-launch-1.0", "-q", "pipewiresrc", f'path={ready["node"]}',
subprocess.run(["gst-launch-1.0", "-q", "pipewiresrc", "min-buffers=4", f'path={ready["node"]}',
"num-buffers=15", "!", "video/x-raw,format=BGRx", "!", "filesink",
f"location={raw}"], env=stream_env, check=True, timeout=20)
pixels = raw.read_bytes()
@@ -118,9 +122,35 @@ class WindowCapture(unittest.TestCase):
blue = start + ((height * 3 // 4) * width + width // 2) * 4
self.assertEqual(pixels[red:red + 3], bytes([0, 0, 255]))
self.assertEqual(pixels[blue:blue + 3], bytes([255, 0, 0]))
worker.stdin.close()
# A live rule change revokes the window stream and synchronous
# window screenshot route, while normal display mapping remains.
config.write_text('window-rule app-id="ferese.test.window-capture" floating=#true\n'
'window-rule title="Capture target" block-out-from-screencasts=#true\n')
call("reload-config")
self.assertEqual(worker.wait(timeout=7), 0)
worker.stdin.close()
worker.stdout.close()
rejected = subprocess.run([str(CTL), "screenshot-window", str(target["id"])],
env=env, capture_output=True, timeout=10)
self.assertNotEqual(rejected.returncode, 0)
self.assertIn(b"protected", rejected.stderr)
self.assertTrue(next(window for window in windows(2) if window["id"] == target["id"])["mapped"])
with Image.open(io.BytesIO(call("screenshot-window", str(cover["id"])))) as image:
self.assertEqual(image.convert("RGB").getpixel((image.width // 2, image.height // 2)), (0, 255, 0))
os.killpg(processes[2].pid, signal.SIGTERM)
processes[2].wait(timeout=5)
protected = windows(1)[0]
self.assertTrue(protected["mapped"])
for args in [("screenshot",), ("screenshot", "--geometry",
f'{protected["x"]},{protected["y"]} {protected["width"]}x{protected["height"]}')]:
with Image.open(io.BytesIO(call(*args))) as image:
self.assertFalse(any(pixel in ((255, 0, 0), (0, 0, 255))
for pixel in image.convert("RGB").getdata()))
# Reusing the capture target after opt-out must produce a fresh frame.
config.write_text('window-rule app-id="ferese.test.window-capture" floating=#true\n')
call("reload-config")
with Image.open(io.BytesIO(call("screenshot-window", str(target["id"])))) as image:
self.assertEqual(image.convert("RGB").getpixel((image.width // 2, image.height // 4)), (255, 0, 0))
finally:
for process in reversed(processes):
if process.poll() is None: