Add workspace screen sharing (#64)
Add workspace selection to the screen-sharing picker with pinned capture, privacy filtering and bounded capture resources. Refresh captured tab labels when title or app-ID metadata changes.
This commit is contained in:
1 parent
e64ab52190
commit
ab11269246
32 files changed
+2125
-165
No files matched your search
+17
-3
@@ -20,6 +20,7 @@ static int private_capture_seen;
|
||||
static int covered;
|
||||
static unsigned updates;
|
||||
static unsigned animation_frames;
|
||||
static unsigned marker_size = 1;
|
||||
static void make_buffer(void);
|
||||
|
||||
static void ping(void *data, struct xdg_wm_base *base, uint32_t serial) {
|
||||
@@ -78,7 +79,14 @@ static void make_buffer(void) {
|
||||
for (size_t i = 0; i < (size_t)width * height; i++) {
|
||||
pixels[i] = covered ? 0xff00ff00 : (i % width < 80 ? 0x80800000 : (i / width < (size_t)height / 2 ? 0xffff0000 : 0xff0000ff));
|
||||
}
|
||||
if (updates) pixels[(size_t)(height / 2) * width + width / 2] = 0xff000000 | updates;
|
||||
if (updates) {
|
||||
for (unsigned y = 0; y < marker_size; y++)
|
||||
for (unsigned x = 0; x < marker_size; x++)
|
||||
// Keep even a one-pixel update visible after interpolation.
|
||||
// Blue encodes progress; red/green alternate with high contrast.
|
||||
pixels[(size_t)(height / 2 + y) * width + width / 2 + x] =
|
||||
0xff000000 | ((updates & 1) ? 0x00ffff00 : 0) | (updates & 0xff);
|
||||
}
|
||||
struct wl_shm_pool *pool = wl_shm_create_pool(shm, fd, bytes);
|
||||
buffer = wl_shm_pool_create_buffer(pool, 0, width, height, width * 4, WL_SHM_FORMAT_ARGB8888);
|
||||
wl_shm_pool_destroy(pool);
|
||||
@@ -93,7 +101,7 @@ static void animate(void *data, struct wl_callback *callback, uint32_t time) {
|
||||
wl_buffer_destroy(buffer);
|
||||
make_buffer();
|
||||
wl_surface_attach(surface, buffer, 0, 0);
|
||||
wl_surface_damage(surface, width / 2, height / 2, 1, 1);
|
||||
wl_surface_damage(surface, width / 2, height / 2, marker_size, marker_size);
|
||||
if (--animation_frames) {
|
||||
struct wl_callback *next = wl_surface_frame(surface);
|
||||
static const struct wl_callback_listener listener = {.done = animate};
|
||||
@@ -104,6 +112,12 @@ static void animate(void *data, struct wl_callback *callback, uint32_t time) {
|
||||
static const struct wl_callback_listener animation_listener = {.done = animate};
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
const char *marker = getenv("FERESE_TEST_CAPTURE_MARKER_SIZE");
|
||||
if (marker) {
|
||||
unsigned value = strtoul(marker, NULL, 10);
|
||||
if (value < 1 || value > 32) return 10;
|
||||
marker_size = value;
|
||||
}
|
||||
struct wl_display *display = wl_display_connect(NULL);
|
||||
if (!display) return 1;
|
||||
struct wl_registry *registry = wl_display_get_registry(display);
|
||||
@@ -143,7 +157,7 @@ int main(int argc, char **argv) {
|
||||
wl_buffer_destroy(buffer);
|
||||
make_buffer();
|
||||
wl_surface_attach(surface, buffer, 0, 0);
|
||||
wl_surface_damage(surface, width / 2, height / 2, 1, 1);
|
||||
wl_surface_damage(surface, width / 2, height / 2, marker_size, marker_size);
|
||||
wl_surface_commit(surface);
|
||||
}
|
||||
if (command == 'r' || command == 'o') {
|
||||
|
||||
@@ -324,8 +324,10 @@ class WindowCapture(unittest.TestCase):
|
||||
self.assertNotEqual(monitor[start:start + 3], bytes([0, 255, 0]),
|
||||
"private occluder leaked into the filtered capture")
|
||||
for index in range(1, 15):
|
||||
self.assertNotEqual(monitor[(index - 1) * frame_bytes:index * frame_bytes], monitor[index * frame_bytes:(index + 1) * frame_bytes],
|
||||
"monitor stream reused stale pixels after source damage")
|
||||
self.assertTrue(
|
||||
monitor[(index - 1) * frame_bytes:index * frame_bytes]
|
||||
!= monitor[index * frame_bytes:(index + 1) * frame_bytes],
|
||||
f"monitor stream reused stale pixels after source damage at frame {index}")
|
||||
worker.stdin.close()
|
||||
self.assertEqual(worker.wait(timeout=7), 0)
|
||||
finally:
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
"""Exercise workspace sharing on an isolated nested desktop and real PipeWire.
|
||||
|
||||
FERESE_TEST_WORKSPACE_CAPTURE=1 python3 scripts/tests/test_workspace_capture_isolated.py
|
||||
Set FERESE_TEST_CAPTURE_TRANSPORT=dma to require DMA-BUF (default: shm).
|
||||
Only synthetic test windows are captured; the host desktop is never a source.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import selectors
|
||||
import signal
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
|
||||
from session_socket import ipc_environment, ipc_socket
|
||||
|
||||
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_WORKSPACE_CAPTURE") == "1", "requires Wayland and PipeWire")
|
||||
class WorkspaceCapture(unittest.TestCase):
|
||||
def test_pinned_workspace_damage_callbacks_privacy_and_resume(self):
|
||||
from PIL import Image
|
||||
children = []
|
||||
with tempfile.TemporaryDirectory(prefix="ferese-workspace-capture-") as temporary:
|
||||
root = Path(temporary)
|
||||
protocols = Path(subprocess.check_output(
|
||||
["pkg-config", "--variable=pkgdatadir", "wayland-protocols"], text=True).strip())
|
||||
xml = protocols / "stable/xdg-shell/xdg-shell.xml"
|
||||
for kind, name in [("client-header", "xdg-shell-client-protocol.h"), ("private-code", "xdg-shell-protocol.c")]:
|
||||
subprocess.run(["wayland-scanner", kind, str(xml), str(root / name)], check=True)
|
||||
for fixture, name, package, extra in [
|
||||
("window-capture.c", "client", "wayland-client", [str(root / "xdg-shell-protocol.c")]),
|
||||
("capture-consumer.c", "consumer", "libpipewire-0.3", []),
|
||||
]:
|
||||
flags = subprocess.check_output(["pkg-config", "--cflags", "--libs", package], text=True).split()
|
||||
subprocess.run(["cc", "-Wall", "-Wextra", "-Werror", "-I", str(root),
|
||||
str(REPO / "scripts/tests/fixtures" / fixture), *extra,
|
||||
"-o", str(root / name), *flags], check=True)
|
||||
runtime = root / "runtime"
|
||||
runtime.mkdir(mode=0o700)
|
||||
config = root / "config/ferese/config.kdl"
|
||||
config.parent.mkdir(parents=True)
|
||||
base_config = ('animations { reduced-motion #true; }\n'
|
||||
'window-rule app-id="ferese.test.window-capture" floating=#true\n')
|
||||
config.write_text(base_config)
|
||||
host_runtime = Path(os.environ["XDG_RUNTIME_DIR"])
|
||||
display = Path(os.environ["WAYLAND_DISPLAY"])
|
||||
if not display.is_absolute():
|
||||
display = host_runtime / display
|
||||
env = dict(os.environ, XDG_RUNTIME_DIR=str(runtime), WAYLAND_DISPLAY=str(display),
|
||||
XDG_CONFIG_HOME=str(root / "config"), XDG_STATE_HOME=str(root / "state"),
|
||||
XDG_CACHE_HOME=str(root / "cache"), FERESE_ENABLE_SCREENCOPY="1",
|
||||
FERESE_TRACE_PERFORMANCE="1", FERESE_TEST_CAPTURE_MARKER_SIZE="16",
|
||||
PIPEWIRE_REMOTE=str(host_runtime / "pipewire-0"))
|
||||
for key in ("WAYLAND_SOCKET", "FERESE_SHELL_CONTROL_SOCKET", "DISPLAY", "XAUTHORITY"):
|
||||
env.pop(key, None)
|
||||
transport = os.environ.get("FERESE_TEST_CAPTURE_TRANSPORT", "shm")
|
||||
if transport == "shm":
|
||||
env["FERESE_CAPTURE_FORCE_SHM"] = "1"
|
||||
else:
|
||||
env.pop("FERESE_CAPTURE_FORCE_SHM", None)
|
||||
log_path = root / "session.log"
|
||||
with log_path.open("w") as log:
|
||||
def launch(command, **kwargs):
|
||||
child = subprocess.Popen(command, env=ipc_environment(env), stderr=log,
|
||||
stdout=kwargs.pop("stdout", log), start_new_session=True, **kwargs)
|
||||
children.append(child)
|
||||
return child
|
||||
|
||||
def until(predicate, message, timeout=10):
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
result = predicate()
|
||||
if result:
|
||||
return result
|
||||
time.sleep(.02)
|
||||
self.fail((message() if callable(message) else message) + "\n" + log_path.read_text())
|
||||
|
||||
def call(*args):
|
||||
return json.loads(subprocess.check_output([str(CTL), "-j", *args], env=ipc_environment(env), timeout=10))
|
||||
|
||||
def change(client, command):
|
||||
client.stdin.write(command)
|
||||
client.stdin.flush()
|
||||
|
||||
try:
|
||||
compositor = launch([str(COMPOSITOR), "--backend=nested"])
|
||||
until(lambda: ipc_socket(runtime).exists(), "nested compositor did not start")
|
||||
env["WAYLAND_DISPLAY"] = str(next(path for path in runtime.glob("wayland-*") if not path.name.endswith(".lock")))
|
||||
target_client = launch([str(root / "client"), "interactive"], stdin=subprocess.PIPE)
|
||||
until(lambda: len(call("windows")) == 1, "target did not map")
|
||||
target = call("windows")[0]
|
||||
workspace = target["workspace"]
|
||||
output = call("outputs")[0]
|
||||
call("workspace", "2")
|
||||
private_client = launch([str(root / "client"), "cover", "interactive"], stdin=subprocess.PIPE)
|
||||
until(lambda: len(call("windows")) == 2, "private workspace did not map")
|
||||
private_workspace = next(window["workspace"] for window in call("windows") if window["id"] != target["id"])
|
||||
|
||||
def usage(process):
|
||||
fields = Path(f"/proc/{process.pid}/stat").read_text().rsplit(")", 1)[1].split()
|
||||
ticks = int(fields[11]) + int(fields[12])
|
||||
rss = int(fields[21]) * os.sysconf("SC_PAGE_SIZE")
|
||||
return ticks, rss
|
||||
|
||||
def idle_cpu(processes, seconds=.6):
|
||||
before = [usage(process) for process in processes]
|
||||
start = time.monotonic()
|
||||
time.sleep(seconds)
|
||||
elapsed = time.monotonic() - start
|
||||
after = [usage(process) for process in processes]
|
||||
return [(100 * (end[0] - begin[0]) / os.sysconf("SC_CLK_TCK") / elapsed, end[1])
|
||||
for begin, end in zip(before, after)]
|
||||
|
||||
time.sleep(1.5)
|
||||
baseline = idle_cpu([compositor], seconds=1.0)[0]
|
||||
|
||||
def worker():
|
||||
child = launch([str(PORTAL), "--stream-workspace", str(workspace), "hidden"],
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE)
|
||||
with selectors.DefaultSelector() as selector:
|
||||
selector.register(child.stdout, selectors.EVENT_READ)
|
||||
self.assertTrue(selector.select(10), log_path.read_text())
|
||||
ready = json.loads(child.stdout.readline())
|
||||
self.assertEqual(ready["logical_size"], [output["width"], output["height"]])
|
||||
self.assertLessEqual(ready["width"] * ready["height"], 1920 * 1080)
|
||||
return child, ready
|
||||
|
||||
stream, ready = worker()
|
||||
frame_bytes = ready["width"] * ready["height"] * 4
|
||||
raw = root / "frames.bgrx"
|
||||
|
||||
def consumer(path, node, frames=1000):
|
||||
return launch([str(root / "consumer"), str(node), transport, str(frames), str(path)])
|
||||
|
||||
first_consumer = consumer(raw, ready["node"])
|
||||
|
||||
def count(path=raw):
|
||||
return path.stat().st_size // frame_bytes if path.exists() else 0
|
||||
|
||||
def image(path=raw):
|
||||
with path.open("rb") as frames:
|
||||
frames.seek((count(path) - 1) * frame_bytes)
|
||||
return Image.frombytes("RGB", (ready["width"], ready["height"]), frames.read(frame_bytes), "raw", "BGRX")
|
||||
|
||||
def pixels(frame):
|
||||
return getattr(frame, "get_flattened_data", frame.getdata)()
|
||||
|
||||
until(lambda: count() == 1, "initial workspace frame missing")
|
||||
scale = ready["width"] / output["width"]
|
||||
red = (round((target["x"] - output["x"] + target["width"] / 2) * scale),
|
||||
round((target["y"] - output["y"] + target["height"] / 4) * scale))
|
||||
self.assertEqual(image().getpixel(red), (255, 0, 0))
|
||||
self.assertNotIn((0, 255, 0), pixels(image()), "private workspace leaked")
|
||||
time.sleep(.5)
|
||||
idle = idle_cpu([compositor, stream], seconds=1.0)
|
||||
print(json.dumps({"transport": transport, "size": [ready["width"], ready["height"]],
|
||||
"baseline_compositor_cpu_percent": round(baseline[0], 2),
|
||||
"sharing_compositor_cpu_percent": round(idle[0][0], 2),
|
||||
"sharing_portal_cpu_percent": round(idle[1][0], 2),
|
||||
"compositor_rss_delta_mib": round((idle[0][1] - baseline[1]) / 1024**2, 2),
|
||||
"portal_rss_mib": round(idle[1][1] / 1024**2, 2)}), flush=True)
|
||||
# Unrelated commits must not compose/copy a new source frame.
|
||||
for _ in range(8):
|
||||
change(private_client, b"d")
|
||||
time.sleep(.04)
|
||||
self.assertEqual(count(), 1, "another workspace woke this capture")
|
||||
# A callback-only commit starts an animation on a hidden workspace.
|
||||
change(target_client, b"a")
|
||||
marker = (round((target["x"] - output["x"] + target["width"] / 2 + 8) * scale),
|
||||
round((target["y"] - output["y"] + target["height"] / 2 + 8) * scale))
|
||||
# Capture coalesces intermediate commits under backpressure.
|
||||
# Require the final animation state, not one frame per commit.
|
||||
until(lambda: count() >= 2 and image().getpixel(marker) == (255, 255, 3),
|
||||
lambda: f"hidden animation stalled at {count()} frames, pixel {image().getpixel(marker)}")
|
||||
animation_count = count()
|
||||
time.sleep(.2)
|
||||
self.assertEqual(count(), animation_count, "static workspace emitted duplicates")
|
||||
# Independent consumers share the composed view and both update.
|
||||
other_stream, other_ready = worker()
|
||||
second_raw = root / "second.bgrx"
|
||||
other_consumer = consumer(second_raw, other_ready["node"])
|
||||
until(lambda: count(second_raw) == 1, "second consumer did not receive cached frame")
|
||||
self.assertEqual(image().tobytes(), image(second_raw).tobytes())
|
||||
for index in range(6):
|
||||
change(target_client, b"d")
|
||||
until(lambda: count() >= animation_count + 1 + index and count(second_raw) >= 2 + index,
|
||||
"one consumer missed source damage")
|
||||
# A changed privacy rule must clear retained pixels in both views.
|
||||
previous = count()
|
||||
config.write_text(base_config + 'window-rule title="Capture target" block-out-from-screencasts=#true\n')
|
||||
call("reload-config")
|
||||
until(lambda: count() > previous, "privacy change did not invalidate retained image")
|
||||
self.assertNotIn((255, 0, 0), pixels(image()))
|
||||
self.assertNotIn((0, 0, 255), pixels(image()))
|
||||
previous = count()
|
||||
config.write_text(base_config)
|
||||
call("reload-config")
|
||||
until(lambda: count() > previous, "privacy opt-in did not repaint")
|
||||
self.assertEqual(image().getpixel(red), (255, 0, 0))
|
||||
# Pause long enough to release targets, then resume unchanged content.
|
||||
for child in [first_consumer, other_consumer]:
|
||||
os.killpg(child.pid, signal.SIGTERM)
|
||||
child.wait(timeout=5)
|
||||
time.sleep(2.3)
|
||||
self.assertIn("released idle workspace capture target", log_path.read_text())
|
||||
resumed = root / "resumed.bgrx"
|
||||
resumed_consumer = consumer(resumed, ready["node"], frames=1)
|
||||
until(lambda: count(resumed) == 1, "resumed consumer did not get an initial frame")
|
||||
resumed_consumer.wait(timeout=5)
|
||||
self.assertEqual(resumed_consumer.returncode, 0)
|
||||
self.assertEqual(image(resumed).getpixel(red), (255, 0, 0))
|
||||
self.assertEqual(next(ws["id"] for ws in call("workspaces") if ws["focused"]), private_workspace)
|
||||
# Closing the source's last window removes that inactive workspace.
|
||||
os.killpg(target_client.pid, signal.SIGTERM)
|
||||
target_client.wait(timeout=5)
|
||||
self.assertEqual(stream.wait(timeout=7), 0)
|
||||
self.assertEqual(other_stream.wait(timeout=7), 0)
|
||||
self.assertIsNone(compositor.poll(), log_path.read_text())
|
||||
finally:
|
||||
for child in reversed(children):
|
||||
if child.poll() is None:
|
||||
os.killpg(child.pid, signal.SIGTERM)
|
||||
try:
|
||||
child.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(child.pid, signal.SIGKILL)
|
||||
child.wait()
|
||||
for pipe in [child.stdin, child.stdout]:
|
||||
if pipe is not None and pipe is not log:
|
||||
pipe.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user