Support isolated live window ScreenCast streams

Render selected toplevels through a private portal-only capture protocol, preserve logical snapshot geometry, and renegotiate PipeWire video on window resizing. Bound outstanding snapshots and retry temporary contention; stop on source closure and session locking.

Validation: portal unit tests (31), capture budget unit, private-bus portal contracts, and real nested/PipeWire isolation, workspace, resize, close and paused-lock tests pass. Independent review cleared. Monitor restore integration timed out on both this build and the unchanged previous compositor under the current nested host.
This commit is contained in:
Samuel Onoja committed 2026-09-30 04:02:33 +01:00
1 parent 56a888adb3
commit baca852ee9
26 files changed
+1307 -139

No files matched your search

+44
View File
@@ -0,0 +1,44 @@
#include <stdio.h>
#include <string.h>
#include <wayland-client.h>
#include "ext-session-lock-client-protocol.h"
static struct ext_session_lock_manager_v1 *manager;
static void global(void *data, struct wl_registry *registry, uint32_t name,
const char *interface, uint32_t version) {
(void)data; (void)version;
if (!strcmp(interface, "ext_session_lock_manager_v1"))
manager = wl_registry_bind(registry, name, &ext_session_lock_manager_v1_interface, 1);
}
static void removed(void *data, struct wl_registry *registry, uint32_t name) {
(void)data; (void)registry; (void)name;
}
static void locked(void *data, struct ext_session_lock_v1 *lock) {
(void)data; (void)lock;
puts("locked");
fflush(stdout);
}
static void finished(void *data, struct ext_session_lock_v1 *lock) {
(void)data; (void)lock;
}
int main(void) {
struct wl_display *display = wl_display_connect(NULL);
if (!display) return 1;
const struct wl_registry_listener registry_listener = {global, removed};
struct wl_registry *registry = wl_display_get_registry(display);
wl_registry_add_listener(registry, &registry_listener, NULL);
if (wl_display_roundtrip(display) < 0 || !manager) return 2;
struct ext_session_lock_v1 *lock = ext_session_lock_manager_v1_lock(manager);
const struct ext_session_lock_v1_listener listener = {locked, finished};
ext_session_lock_v1_add_listener(lock, &listener, NULL);
if (wl_display_roundtrip(display) < 0) return 3;
puts("requested");
fflush(stdout);
while (wl_display_dispatch(display) != -1) {}
return 0;
}
+49 -16
View File
@@ -1,6 +1,7 @@
/* Two overlapping clients with distinct colors for isolated capture tests. */
#define _GNU_SOURCE
#include <stdint.h>
#include <poll.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
@@ -14,6 +15,9 @@ static struct wl_shm *shm;
static struct xdg_wm_base *wm;
static struct wl_surface *surface;
static struct wl_buffer *buffer;
static int width = 640, height = 480;
static int private_capture_seen;
static int covered;
static void ping(void *data, struct xdg_wm_base *base, uint32_t serial) {
(void)data;
@@ -24,6 +28,7 @@ static const struct xdg_wm_base_listener wm_listener = {.ping = ping};
static void global(void *data, struct wl_registry *registry, uint32_t name,
const char *interface, uint32_t version) {
(void)data; (void)version;
if (!strcmp(interface, "ferese_window_capture_manager_v1")) private_capture_seen = 1;
if (!strcmp(interface, "wl_compositor"))
compositor = wl_registry_bind(registry, name, &wl_compositor_interface, 4);
else if (!strcmp(interface, "wl_shm"))
@@ -42,9 +47,9 @@ static const struct wl_registry_listener registry_listener = {global, removed};
static void configure(void *data, struct xdg_surface *xdg, uint32_t serial) {
(void)data;
xdg_surface_ack_configure(xdg, serial);
xdg_surface_set_window_geometry(xdg, 0, 0, 640, 480);
xdg_surface_set_window_geometry(xdg, 0, 0, width, height);
wl_surface_attach(surface, buffer, 0, 0);
wl_surface_damage(surface, 0, 0, 640, 480);
wl_surface_damage(surface, 0, 0, width, height);
wl_surface_commit(surface);
}
static const struct xdg_surface_listener surface_listener = {.configure = configure};
@@ -59,25 +64,31 @@ static void close_window(void *data, struct xdg_toplevel *top) {
}
static const struct xdg_toplevel_listener top_listener = {.configure = size, .close = close_window};
static void make_buffer(void) {
int fd = memfd_create("floating-size-test", MFD_CLOEXEC);
size_t bytes = width * height * 4;
if (fd < 0 || ftruncate(fd, bytes)) exit(3);
uint32_t *pixels = mmap(NULL, bytes, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
if (pixels == MAP_FAILED) exit(4);
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));
}
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);
close(fd);
munmap(pixels, bytes);
}
int main(int argc, char **argv) {
struct wl_display *display = wl_display_connect(NULL);
if (!display) return 1;
struct wl_registry *registry = wl_display_get_registry(display);
wl_registry_add_listener(registry, &registry_listener, NULL);
if (wl_display_roundtrip(display) < 0 || !compositor || !shm || !wm) return 2;
int fd = memfd_create("floating-size-test", MFD_CLOEXEC);
size_t bytes = 640 * 480 * 4;
if (fd < 0 || ftruncate(fd, bytes)) return 3;
uint32_t *pixels = mmap(NULL, bytes, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
if (pixels == MAP_FAILED) return 4;
int covered = argc > 1 && !strcmp(argv[1], "cover");
for (size_t i = 0; i < 640 * 480; i++) {
pixels[i] = covered ? 0xff00ff00 : (i % 640 < 80 ? 0x80800000 : (i / 640 < 240 ? 0xffff0000 : 0xff0000ff));
}
struct wl_shm_pool *pool = wl_shm_create_pool(shm, fd, bytes);
buffer = wl_shm_pool_create_buffer(pool, 0, 640, 480, 640 * 4, WL_SHM_FORMAT_ARGB8888);
wl_shm_pool_destroy(pool);
close(fd);
if (private_capture_seen) return 9;
covered = argc > 1 && !strcmp(argv[1], "cover");
make_buffer();
surface = wl_compositor_create_surface(compositor);
struct xdg_surface *xdg = xdg_wm_base_get_xdg_surface(wm, surface);
xdg_surface_add_listener(xdg, &surface_listener, NULL);
@@ -86,6 +97,28 @@ int main(int argc, char **argv) {
xdg_toplevel_set_app_id(top, "ferese.test.window-capture");
xdg_toplevel_set_title(top, covered ? "Occluding window" : "Capture target");
wl_surface_commit(surface);
while (wl_display_dispatch(display) != -1) {}
if (argc > 1 && !strcmp(argv[1], "resize")) {
while (1) {
wl_display_flush(display);
struct pollfd fds[2] = {{wl_display_get_fd(display), POLLIN, 0}, {STDIN_FILENO, POLLIN, 0}};
if (poll(fds, 2, -1) < 0) break;
if (fds[0].revents & POLLIN && wl_display_dispatch(display) < 0) break;
if (fds[1].revents & POLLIN) {
char command;
if (read(STDIN_FILENO, &command, 1) != 1) break;
if (command == 'r') {
width = 480; height = 320;
wl_buffer_destroy(buffer);
make_buffer();
xdg_surface_set_window_geometry(xdg, 0, 0, width, height);
wl_surface_attach(surface, buffer, 0, 0);
wl_surface_damage(surface, 0, 0, width, height);
wl_surface_commit(surface);
}
}
}
} else {
while (wl_display_dispatch(display) != -1) {}
}
return 0;
}
+3 -1
View File
@@ -112,11 +112,13 @@ def private_bus_checks():
assert request_name == 1
version, = call("org.freedesktop.DBus.Properties", "Get", GLib.Variant("(ss)", ("org.freedesktop.impl.portal.ScreenCast", "version")), "(v)")
assert version == 4
source_types, = call("org.freedesktop.DBus.Properties", "Get", GLib.Variant("(ss)", ("org.freedesktop.impl.portal.ScreenCast", "AvailableSourceTypes")), "(v)")
assert source_types == 3
for mode in (0, 1, 2):
session = PATH + f"/session/test/persist{mode}"
handle = PATH + f"/request/test/persist{mode}"
assert call("org.freedesktop.impl.portal.ScreenCast", "CreateSession", GLib.Variant("(oosa{sv})", (handle, session, "org.test.Share", {})), "(ua{sv})") == (0, {})
options = {"persist_mode": GLib.Variant("u", mode), "restore_data": GLib.Variant("(suv)", ("Other", 9, GLib.Variant("s", "foreign data")))}
options = {"types": GLib.Variant("u", (1, 2, 3)[mode]), "persist_mode": GLib.Variant("u", mode), "restore_data": GLib.Variant("(suv)", ("Other", 9, GLib.Variant("s", "foreign data")))}
assert call("org.freedesktop.impl.portal.ScreenCast", "SelectSources", GLib.Variant("(oosa{sv})", (handle, session, "org.test.Share", options)), "(ua{sv})") == (0, {})
bus.call_sync(NAME, session, "org.freedesktop.impl.portal.Session", "Close", None, None, Gio.DBusCallFlags.NONE, 5000, None)
session = PATH + "/session/test/badpersist"
@@ -0,0 +1,163 @@
"""Exercise live isolated window sharing in a disposable nested compositor.
FERESE_TEST_WINDOW_STREAM=1 python3 scripts/tests/test_window_stream_isolated.py
Requires a Wayland host, PipeWire, GStreamer Python bindings and a C compiler.
Only this test's compositor, windows and streams are stopped.
"""
import json
import os
from pathlib import Path
import select
import signal
import subprocess
import tempfile
import time
import unittest
from test_inhibit_isolated import IPC
REPO = Path(__file__).resolve().parents[2]
@unittest.skipUnless(os.environ.get("FERESE_TEST_WINDOW_STREAM") == "1", "requires Wayland and PipeWire")
class WindowStream(unittest.TestCase):
def test_isolation_resize_workspace_and_close(self):
import gi
gi.require_version("Gst", "1.0")
from gi.repository import Gst
Gst.init(None)
processes = []
pipeline = None
admin = None
def wait_for(check, message):
deadline = time.monotonic() + 10
while time.monotonic() < deadline:
value = check()
if value:
return value
time.sleep(0.02)
self.fail(message)
with tempfile.TemporaryDirectory(prefix="ferese-window-stream-") as directory:
root = Path(directory)
protocols = subprocess.check_output(["pkg-config", "--variable=pkgdatadir", "wayland-protocols"], text=True).strip()
xml = str(Path(protocols) / "stable/xdg-shell/xdg-shell.xml")
for kind, output in [("client-header", "xdg-shell-client-protocol.h"), ("private-code", "xdg-shell-protocol.c")]:
subprocess.run(["wayland-scanner", kind, xml, str(root / output)], check=True)
flags = subprocess.check_output(["pkg-config", "--cflags", "--libs", "wayland-client"], text=True).split()
subprocess.run(["cc", "-Wall", "-Wextra", "-I", str(root), str(REPO / "scripts/tests/fixtures/window-capture.c"), str(root / "xdg-shell-protocol.c"), "-o", str(root / "client"), *flags], check=True)
lock_xml = str(Path(protocols) / "staging/ext-session-lock/ext-session-lock-v1.xml")
for kind, output in [("client-header", "ext-session-lock-client-protocol.h"), ("private-code", "ext-session-lock-protocol.c")]:
subprocess.run(["wayland-scanner", kind, lock_xml, str(root / output)], check=True)
subprocess.run(["cc", "-Wall", "-Wextra", "-I", str(root), str(REPO / "scripts/tests/fixtures/session-lock.c"), str(root / "ext-session-lock-protocol.c"), "-o", str(root / "locker"), *flags], check=True)
runtime = root / "runtime"
runtime.mkdir(mode=0o700)
config = root / "config/ferese/config.kdl"
config.parent.mkdir(parents=True)
config.write_text('window-rule app-id="ferese.test.window-capture" floating=#true\nstatus { keybinding-guide #false; }\n')
host_runtime = os.environ["XDG_RUNTIME_DIR"]
display = Path(os.environ["WAYLAND_DISPLAY"])
if not display.is_absolute():
display = Path(host_runtime) / display
env = dict(os.environ, XDG_RUNTIME_DIR=str(runtime), XDG_CONFIG_HOME=str(root / "config"), XDG_DATA_HOME=str(root / "data"), WAYLAND_DISPLAY=str(display), PIPEWIRE_RUNTIME_DIR=host_runtime, FERESE_ENABLE_SCREENCOPY="1")
env.pop("WAYLAND_SOCKET", None)
env.pop("FERESE_SHELL_CONTROL_SOCKET", None)
log = (root / "test.log").open("w")
try:
compositor = subprocess.Popen([str(REPO / "target/debug/ferese"), "--backend=nested"], env=env, stdout=log, stderr=log, start_new_session=True)
processes.append(compositor)
socket = runtime / "ferese/control.sock"
wait_for(socket.exists, "Nested compositor did not start")
admin = IPC(socket)
sockets = [path for path in runtime.glob("wayland-*") if not path.name.endswith(".lock")]
env["WAYLAND_DISPLAY"] = str(sockets[0])
target = subprocess.Popen([str(root / "client"), "resize"], env=env, stdin=subprocess.PIPE, stdout=log, stderr=log, start_new_session=True)
processes.append(target)
windows = wait_for(lambda: [window for window in admin.call("get-windows") if window["capture_width"] == 640], "Window fixture did not map; private manager must be hidden from ordinary clients")
target_id = windows[0]["id"]
cover = subprocess.Popen([str(root / "client"), "cover"], env=env, stdout=log, stderr=log, start_new_session=True)
processes.append(cover)
wait_for(lambda: len(admin.call("get-windows")) == 2, "Occluding window did not map")
helper = subprocess.Popen([str(REPO / "target/debug/xdg-desktop-portal-ferese"), "--stream-window", str(target_id), "hidden"], env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=log, text=True, start_new_session=True)
processes.append(helper)
self.assertTrue(select.select([helper.stdout], [], [], 10)[0], "Stream did not announce a PipeWire node")
ready = json.loads(helper.stdout.readline())
self.assertEqual(ready["logical_size"], [640, 480])
pipeline = Gst.parse_launch(f'pipewiresrc path={ready["node"]} ! video/x-raw,format=BGRx ! appsink name=frames sync=false max-buffers=1 drop=true')
sink = pipeline.get_by_name("frames")
pipeline.set_state(Gst.State.PLAYING)
def frame(expected=None):
deadline = time.monotonic() + 8
while time.monotonic() < deadline:
sample = sink.emit("try-pull-sample", Gst.SECOND)
if sample is None:
continue
caps = sample.get_caps().get_structure(0)
size = (caps.get_value("width"), caps.get_value("height"))
if expected and size != expected:
continue
buffer = sample.get_buffer()
pixels = buffer.extract_dup(0, buffer.get_size())
width, height = size
self.assertEqual(tuple(pixels[((height // 4) * width + width // 2) * 4:][:3]), (0, 0, 255))
self.assertEqual(tuple(pixels[((height * 3 // 4) * width + width // 2) * 4:][:3]), (255, 0, 0))
return size
message = pipeline.get_bus().pop_filtered(Gst.MessageType.ERROR)
self.fail(f"Missing expected frame {expected}; pipeline error: {message.parse_error() if message else None}")
original = frame()
self.assertEqual(original, (ready["width"], ready["height"]))
admin.call("workspace", {"index": 2})
time.sleep(0.4)
frame(original)
self.assertIsNone(helper.poll(), "Inactive workspace stopped the stream")
target.stdin.write(b"r")
target.stdin.flush()
scale = ready["width"] / 640
resized = (round(480 * scale), round(320 * scale))
frame(resized)
self.assertIsNone(helper.poll(), "Resize stopped the stream")
target.terminate()
target.wait(timeout=3)
wait_for(lambda: helper.poll() is not None, "Closing the selected window left capture running")
self.assertIsNone(cover.poll(), "Unrelated window was closed")
pipeline.set_state(Gst.State.NULL)
pipeline = None
cover_id = next(window["id"] for window in admin.call("get-windows") if window["title"] == "Occluding window")
paused = subprocess.Popen([str(REPO / "target/debug/xdg-desktop-portal-ferese"), "--stream-window", str(cover_id), "hidden"], env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=log, text=True, start_new_session=True)
processes.append(paused)
self.assertTrue(select.select([paused.stdout], [], [], 10)[0])
self.assertGreater(json.loads(paused.stdout.readline())["width"], 0)
locker = subprocess.Popen([str(root / "locker")], env=env, stdout=subprocess.PIPE, stderr=log, text=True, start_new_session=True)
processes.append(locker)
self.assertTrue(select.select([locker.stdout], [], [], 5)[0], "Nested lock request did not complete")
self.assertIn(locker.stdout.readline().strip(), ("locked", "requested"))
wait_for(lambda: paused.poll() is not None, "Lock did not end a paused window stream")
print("PASS: isolated pixels, private manager, upright video, inactive workspace, resize renegotiation, closure and paused lock revocation")
except Exception:
print((root / "test.log").read_text())
raise
finally:
if pipeline:
pipeline.set_state(Gst.State.NULL)
if admin:
admin.close()
for process in reversed(processes):
if process.poll() is None:
os.killpg(process.pid, signal.SIGTERM)
try:
process.wait(timeout=3)
except subprocess.TimeoutExpired:
os.killpg(process.pid, signal.SIGKILL)
process.wait()
for process in processes:
for pipe in (process.stdin, process.stdout, process.stderr):
if pipe:
pipe.close()
log.close()
if __name__ == "__main__":
unittest.main()