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:
1 parent
bffd538dd4
commit
453ec16583
24 files changed
+1132
-41
No files matched your search
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user