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:
1 parent
56a888adb3
commit
baca852ee9
26 files changed
+1307
-139
No files matched your search
+44
@@ -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, ®istry_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
@@ -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, ®istry_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;
|
||||
}
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user