Add native ScreenCast portal with PipeWire monitor streams

This commit is contained in:
Samuel Onoja committed 2026-09-28 07:54:22 +01:00
1 parent cb512eece9
commit e7d92280d6
21 files changed
+2228 -15

No files matched your search

+1
View File
@@ -18,6 +18,7 @@ PAGES = [
('desktop-widgets', 'Desktop widgets', ROOT / 'docs/desktop-widgets.md'),
('notifications', 'Notifications', ROOT / 'docs/notifications.md'),
('locking', 'Native locker', ROOT / 'docs/locking.md'),
('screen-sharing', 'Screen sharing', ROOT / 'docs/screen-sharing.md'),
]
md = MarkdownIt('commonmark', {'html': False}).enable('table')
OUTPUT.mkdir(parents=True, exist_ok=True)
+23 -4
View File
@@ -19,7 +19,7 @@ session_entry=/usr/share/wayland-sessions/ferese.desktop
[[ ! -e $release_dir ]] || { echo "Release already exists: $release_dir" >&2; exit 1; }
# Refuse to overwrite unrelated installations.
for name in ferese ferese-shell ferese-settings feresectl ferese-session ferese-lock ferese-screenshot; do
for name in ferese ferese-shell ferese-settings feresectl ferese-session ferese-lock ferese-screenshot xdg-desktop-portal-ferese; do
link=/usr/local/bin/$name
if [[ -e $link || -L $link ]]; then
[[ -L $link && $(readlink -- "$link") == "$install_root/current/$name" ]] || {
@@ -39,7 +39,7 @@ if [[ -e $session_entry ]]; then
echo "Different session entry exists: $session_entry" >&2; exit 1;
}
fi
for name in ferese ferese-shell ferese-settings feresectl ferese-lock; do
for name in ferese ferese-shell ferese-settings feresectl ferese-lock xdg-desktop-portal-ferese; do
[[ -x $repo_dir/target/release/$name ]] || { echo "Missing release binary: $name" >&2; exit 1; }
done
for name in ferese.png ferese.svg; do
@@ -57,6 +57,21 @@ elif [[ ! -f /etc/pam.d/login && ! -f /etc/pam.d/ferese-lock ]]; then
exit 1
fi
# These files are owned by Ferese; preserve unrelated administrator configuration.
for entry in \
'ferese.portal:portals/ferese.portal' \
'ferese-portals.conf:ferese-portals.conf'; do
source=${entry%%:*}
destination=/usr/share/xdg-desktop-portal/${entry#*:}
if [[ -e $destination ]] && ! cmp -s "$repo_dir/packaging/portal/$source" "$destination"; then
echo "Unmanaged portal configuration exists: $destination" >&2; exit 1
fi
done
service=/usr/local/share/dbus-1/services/org.freedesktop.impl.portal.desktop.ferese.service
if [[ -e $service ]] && ! cmp -s "$repo_dir/packaging/portal/$(basename "$service")" "$service"; then
echo "Unmanaged portal service exists: $service" >&2; exit 1
fi
desktop-file-validate "$repo_dir/packaging/ferese.desktop"
desktop-file-validate "$repo_dir/packaging/dev.ferese.Settings.desktop"
@@ -64,10 +79,11 @@ install -d -m 0755 -- "$release_dir" /usr/local/bin /usr/share/wayland-sessions
if [[ -e $session_entry ]]; then
install -m 0644 -- "$session_entry" "$release_dir/session.previous.desktop"
fi
for name in ferese ferese-shell ferese-settings feresectl ferese-lock; do
for name in ferese ferese-shell ferese-settings feresectl ferese-lock xdg-desktop-portal-ferese; do
install -m 0755 -- "$repo_dir/target/release/$name" "$release_dir/$name"
done
install -m 0755 -- "$repo_dir/packaging/ferese-session" "$release_dir/ferese-session"
install -m 0755 -- "$repo_dir/packaging/ferese-session-shell" "$release_dir/ferese-session-shell"
install -m 0755 -- "$repo_dir/packaging/ferese-screenshot" "$release_dir/ferese-screenshot"
install -m 0644 -- "$repo_dir/packaging/config.kdl" "$release_dir/config.example.kdl"
install -D -m 0644 -- "$repo_dir/assets/fonts/Comfortaa-LICENSE.txt" "$release_dir/licenses/Comfortaa-LICENSE.txt"
@@ -85,12 +101,15 @@ if [[ -L $install_root/current ]]; then
fi
ln -s -- "releases/$release_id" "$install_root/current-$release_id"
mv -Tf -- "$install_root/current-$release_id" "$install_root/current"
for name in ferese ferese-shell ferese-settings feresectl ferese-session ferese-lock ferese-screenshot; do
for name in ferese ferese-shell ferese-settings feresectl ferese-session ferese-lock ferese-screenshot xdg-desktop-portal-ferese; do
ln -sfn -- "$install_root/current/$name" "/usr/local/bin/$name"
done
install -m 0644 -- "$repo_dir/packaging/ferese.desktop" "$session_entry"
install -D -m 0644 -- "$repo_dir/packaging/dev.ferese.Settings.desktop" /usr/local/share/applications/dev.ferese.Settings.desktop
install -D -m 0644 -- "$repo_dir/packaging/icons/ferese.svg" /usr/local/share/icons/hicolor/scalable/apps/dev.ferese.Settings.svg
install -D -m 0644 -- "$repo_dir/packaging/icons/ferese.svg" /usr/local/share/icons/hicolor/scalable/apps/ferese.svg
install -D -m 0644 -- "$repo_dir/packaging/portal/ferese.portal" /usr/share/xdg-desktop-portal/portals/ferese.portal
install -D -m 0644 -- "$repo_dir/packaging/portal/ferese-portals.conf" /usr/share/xdg-desktop-portal/ferese-portals.conf
install -D -m 0644 -- "$repo_dir/packaging/portal/org.freedesktop.impl.portal.desktop.ferese.service" "$service"
if command -v update-desktop-database >/dev/null; then update-desktop-database /usr/local/share/applications || true; fi
echo "Installed Ferese $release_id. Select Ferese at your login screen, or run /usr/local/bin/ferese-session from a local TTY."
+1 -1
View File
@@ -49,7 +49,7 @@ desktop-file-validate "$repo_dir/packaging/ferese.desktop"
build=(cargo build --manifest-path "$repo_dir/Cargo.toml"
--target-dir "$repo_dir/target" --release --locked
-p ferese -p ferese-shell -p ferese-settings -p feresectl -p ferese-lock)
-p ferese -p ferese-shell -p ferese-settings -p feresectl -p ferese-lock -p xdg-desktop-portal-ferese)
if $offline; then build+=(--offline); fi
if ! $skip_build; then
((EUID != 0)) || fail 'build as your normal user, without sudo (or use --skip-build)'
+199
View File
@@ -0,0 +1,199 @@
"""Opt-in portal integration test; only captures a temporary nested compositor.
FERESE_TEST_PORTAL=1 python3 scripts/tests/test_portal_isolated.py
Add FERESE_TEST_PORTAL_CONSENT=1 to exercise the actual picker interactively.
Requires built binaries, Python GI/GStreamer (pipewiresrc), dbus-daemon,
xdg-desktop-portal and bwrap. Host PAM and D-Bus activation are never modified.
"""
import json
import os
from pathlib import Path
import selectors
import signal
import subprocess
import tempfile
import time
import unittest
@unittest.skipUnless(os.environ.get("FERESE_TEST_PORTAL") == "1", "nested portal test is opt-in")
class PortalTest(unittest.TestCase):
def test_portal_capture_cancel_and_lock(self):
import gi
gi.require_version("Gst", "1.0")
from gi.repository import Gio, GLib, Gst
Gst.init(None)
repo = Path(__file__).resolve().parents[2]
binary = repo / os.environ.get("FERESE_TEST_PORTAL_BINARY", "target/debug/xdg-desktop-portal-ferese")
compositor_binary = repo / os.environ.get("FERESE_TEST_BINARY", "target/release/ferese")
processes, logs = [], []
with tempfile.TemporaryDirectory(prefix="ferese-portal-test-") as tmp:
root = Path(tmp)
runtime = root / "runtime"
runtime.mkdir(mode=0o700)
config = root / "config/ferese"
config.mkdir(parents=True)
(config / "config.kdl").write_text('window-rule app-id="dev.ferese.ScreenShare" floating=#true\n')
display = Path(os.environ["WAYLAND_DISPLAY"])
if not display.is_absolute():
display = Path(os.environ["XDG_RUNTIME_DIR"]) / display
env = dict(os.environ, XDG_RUNTIME_DIR=str(runtime), XDG_CONFIG_HOME=str(root / "config"),
WAYLAND_DISPLAY=str(display), FERESE_ENABLE_SCREENCOPY="1",
PIPEWIRE_RUNTIME_DIR=os.environ["XDG_RUNTIME_DIR"], XDG_CURRENT_DESKTOP="Ferese",
XDG_DESKTOP_PORTAL_DIR=str(repo / "packaging/portal"))
def launch(name, command, **kwargs):
log = (root / (name + ".log")).open("w")
logs.append(log)
child = subprocess.Popen(command, env=env, stderr=log,
stdout=kwargs.pop("stdout", log), start_new_session=True, **kwargs)
processes.append(child)
return child
def until(predicate, seconds=10):
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
if predicate():
return
while GLib.MainContext.default().iteration(False):
pass
time.sleep(.02)
self.fail("Timed out waiting for test condition")
def read_node(worker):
with selectors.DefaultSelector() as selector:
selector.register(worker.stdout, selectors.EVENT_READ)
self.assertTrue(selector.select(10), "stream did not publish a node")
return json.loads(worker.stdout.readline())["node"]
def consume(node, fd=None):
source = f'pipewiresrc path={node} num-buffers=15'
if fd is not None:
source += f' fd={fd}'
pipeline = Gst.parse_launch(source + ' ! video/x-raw,format=BGRx ! appsink name=sink sync=false emit-signals=true')
sizes = []
def sample(sink):
buffer = sink.emit("pull-sample").get_buffer()
sizes.append(buffer.get_size())
return Gst.FlowReturn.OK
pipeline.get_by_name("sink").connect("new-sample", sample)
try:
pipeline.set_state(Gst.State.PLAYING)
message = pipeline.get_bus().timed_pop_filtered(15 * Gst.SECOND, Gst.MessageType.EOS | Gst.MessageType.ERROR)
self.assertIsNotNone(message, "PipeWire consumer timed out")
self.assertEqual(message.type, Gst.MessageType.EOS)
self.assertEqual(len(sizes), 15)
self.assertGreater(min(sizes), 0)
finally:
pipeline.set_state(Gst.State.NULL)
try:
launch("compositor", [str(compositor_binary), "--backend", "nested"])
sockets = lambda: [p for p in runtime.glob("wayland-*") if not p.name.endswith(".lock")]
until(sockets)
env["WAYLAND_DISPLAY"] = str(sockets()[0])
# Private bus, including the real portal frontend and permission store.
bus = launch("bus", ["dbus-daemon", "--session", "--nofork", "--print-address=1"], stdout=subprocess.PIPE)
env["DBUS_SESSION_BUS_ADDRESS"] = bus.stdout.readline().decode().strip()
connection = Gio.DBusConnection.new_for_address_sync(env["DBUS_SESSION_BUS_ADDRESS"],
Gio.DBusConnectionFlags.AUTHENTICATION_CLIENT | Gio.DBusConnectionFlags.MESSAGE_BUS_CONNECTION, None, None)
backend = launch("backend", [str(binary)])
frontend = launch("frontend", [os.environ.get("FERESE_TEST_PORTAL_FRONTEND", "/usr/libexec/xdg-desktop-portal")])
name, path, iface = "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.ScreenCast"
responses = {}
connection.signal_subscribe(name, "org.freedesktop.portal.Request", "Response", None, None,
Gio.DBusSignalFlags.NONE, lambda c, s, p, i, n, args, _: responses.update({p: args.unpack()}), None)
def call(method, args):
return connection.call_sync(name, path, iface, method, args, GLib.VariantType("(o)"),
Gio.DBusCallFlags.NONE, 10000, None).unpack()[0]
def request(method, args, timeout=10):
handle = call(method, args)
until(lambda: handle in responses, timeout)
code, result = responses.pop(handle)
self.assertEqual(code, 0)
return result
def has_frontend():
return connection.call_sync("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus",
"NameHasOwner", GLib.Variant("(s)", (name,)), GLib.VariantType("(b)"), Gio.DBusCallFlags.NONE, 1000, None).unpack()[0]
until(has_frontend)
# Direct implementation calls must not bypass the public frontend.
with self.assertRaises(GLib.Error):
connection.call_sync("org.freedesktop.impl.portal.desktop.ferese", path, "org.freedesktop.impl.portal.ScreenCast",
"CreateSession", GLib.Variant("(oosa{sv})", (path + "/request/test", path + "/session/test", "", {})),
None, Gio.DBusCallFlags.NONE, 5000, None)
def create(token):
session = request("CreateSession", GLib.Variant("(a{sv})", ({"session_handle_token": GLib.Variant("s", token)},)))["session_handle"]
request("SelectSources", GLib.Variant("(oa{sv})", (session, {"types": GLib.Variant("u", 1)})))
return session
def helpers():
return [pid for children in Path(f"/proc/{backend.pid}/task").glob("*/children")
for pid in children.read_text().split()]
session = create("canceltest")
handle = call("Start", GLib.Variant("(osa{sv})", (session, "", {})))
until(helpers)
connection.call_sync(name, handle, "org.freedesktop.portal.Request", "Close", None, None, Gio.DBusCallFlags.NONE, 5000, None)
until(lambda: not helpers())
print("PASS: public session negotiation, caller authorization, request cancellation", flush=True)
if os.environ.get("FERESE_TEST_PORTAL_CONSENT") == "1":
session = create("consenttest")
print("Select the temporary display and click Share in the nested preview.", flush=True)
result = request("Start", GLib.Variant("(osa{sv})", (session, "", {})), 120)
reply, fds = connection.call_with_unix_fd_list_sync(name, path, iface, "OpenPipeWireRemote",
GLib.Variant("(oa{sv})", (session, {})), GLib.VariantType("(h)"), Gio.DBusCallFlags.NONE, 5000, None, None)
fd = fds.get(reply.unpack()[0])
try:
consume(result["streams"][0][0], fd)
finally:
os.close(fd)
connection.call_sync(name, session, "org.freedesktop.portal.Session", "Close", None, None, Gio.DBusCallFlags.NONE, 5000, None)
print("PASS: native consent and video through restricted portal FD", flush=True)
session = create("frontenddeath")
call("Start", GLib.Variant("(osa{sv})", (session, "", {})))
until(helpers)
frontend.terminate()
frontend.wait(timeout=5)
until(lambda: not helpers())
print("PASS: frontend death revokes pending consent", flush=True)
sources = json.loads(subprocess.check_output([str(binary), "--sources"], env=env, timeout=10))
worker = launch("capture", [str(binary), "--stream", sources[0]["name"], "hidden"], stdin=subprocess.PIPE, stdout=subprocess.PIPE)
node = read_node(worker)
consume(node)
worker.stdin.close()
self.assertEqual(worker.wait(timeout=7), 0)
print("PASS: frame delivery and control-pipe shutdown", flush=True)
# Test revocation with NO consumer: paused captures must also end on lock.
worker = launch("paused", [str(binary), "--stream", sources[0]["name"], "hidden"], stdin=subprocess.PIPE, stdout=subprocess.PIPE)
read_node(worker)
pam = root / "pam"
pam.mkdir()
(pam / "ferese-lock").write_text("auth required pam_deny.so\naccount required pam_deny.so\n")
env["FERESE_LOCK_READY"] = "1"
locker = launch("locker", ["bwrap", "--bind", "/", "/", "--dev-bind", "/dev", "/dev", "--ro-bind", str(pam), "/etc/pam.d", "--unshare-user", "--", str(repo / "target/release/ferese-lock")])
self.assertEqual(locker.wait(timeout=20), 0)
self.assertEqual(worker.wait(timeout=7), 0)
print("PASS: paused capture revoked by session lock", flush=True)
except BaseException:
for log in logs:
log.flush()
print(Path(log.name).name, Path(log.name).read_text()[-3000:])
raise
finally:
for child in reversed(processes):
try:
os.killpg(child.pid, signal.SIGTERM)
except ProcessLookupError:
pass
try:
child.wait(timeout=3)
except subprocess.TimeoutExpired:
os.killpg(child.pid, signal.SIGKILL)
child.wait()
for stream in (child.stdin, child.stdout):
if stream is not None:
stream.close()
for log in logs:
log.close()
if __name__ == "__main__":
unittest.main()