Restore explicitly approved ScreenCast display permissions

This commit is contained in:
Samuel Onoja committed 2026-09-30 03:12:13 +01:00
1 parent da1428dd00
commit 56a888adb3
11 files changed
+882 -52

No files matched your search

+17
View File
@@ -110,6 +110,23 @@ def private_bus_checks():
GLib.Variant("(su)", ("org.freedesktop.portal.Desktop", 0)), GLib.VariantType.new("(u)"),
Gio.DBusCallFlags.NONE, 5000, None).unpack()[0]
assert request_name == 1
version, = call("org.freedesktop.DBus.Properties", "Get", GLib.Variant("(ss)", ("org.freedesktop.impl.portal.ScreenCast", "version")), "(v)")
assert version == 4
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")))}
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"
handle = PATH + "/request/test/badpersist"
call("org.freedesktop.impl.portal.ScreenCast", "CreateSession", GLib.Variant("(oosa{sv})", (handle, session, "org.test.Share", {})))
try:
call("org.freedesktop.impl.portal.ScreenCast", "SelectSources", GLib.Variant("(oosa{sv})", (handle, session, "org.test.Share", {"persist_mode": GLib.Variant("u", 3)})))
raise AssertionError("Invalid persistence mode succeeded")
except GLib.Error as error:
assert "InvalidArgs" in str(error), error
response, values = call("org.freedesktop.impl.portal.Screenshot", "Screenshot", params, "(ua{sv})")
assert response == 2 and values == {}
+216
View File
@@ -0,0 +1,216 @@
"""Check restored monitor sharing on a private bus and nested compositor.
FERESE_TEST_RESTORE=1 python3 scripts/tests/test_restore_isolated.py
The IPC fixture supplies synthetic EDID identities for the nested display.
No host permission store, compositor configuration or portal service is changed.
"""
import json
import os
from pathlib import Path
import signal
import socket
import struct
import subprocess
import sys
import tempfile
import threading
import time
import unittest
from test_inhibit_isolated import IPC
REPO = Path(__file__).resolve().parents[2]
NAME = "org.freedesktop.impl.portal.desktop.ferese"
PATH = "/org/freedesktop/portal/desktop"
INTERFACE = "org.freedesktop.impl.portal.ScreenCast"
@unittest.skipUnless(os.environ.get("FERESE_TEST_RESTORE") == "1", "requires a Wayland host and PipeWire")
class RestoreIntegration(unittest.TestCase):
def test_private_bus_restore_lifetimes(self):
result = subprocess.run(["dbus-run-session", "--", sys.executable, __file__, "--private"], capture_output=True, text=True, timeout=70)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def private_test():
import gi
gi.require_version("Gio", "2.0")
gi.require_version("Gst", "1.0")
from gi.repository import Gio, GLib, Gst
Gst.init(None)
processes = []
stop = threading.Event()
proxy = None
def wait_for(check, message, seconds=10):
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
while GLib.MainContext.default().pending():
GLib.MainContext.default().iteration(False)
if check():
return
time.sleep(0.02)
raise AssertionError(message)
def proxy_connection(client, destination):
with client:
upstream = IPC(destination)
try:
def read(size):
result = b""
while len(result) < size:
chunk = client.recv(size - len(result))
if not chunk:
raise EOFError()
result += chunk
return result
while not stop.is_set():
request = json.loads(read(struct.unpack(">I", read(4))[0]))
result = upstream.call(request["command"], request.get("args"))
if request["command"] == "get-outputs":
for output in result:
output["identity"] = "drm-edid:test-" + output["name"]
reply = json.dumps({"version": 1, "id": request["id"], "result": result}).encode()
client.sendall(struct.pack(">I", len(reply)) + reply)
except (EOFError, OSError):
pass
finally:
upstream.close()
with tempfile.TemporaryDirectory(prefix="ferese-restore-") as directory:
root = Path(directory)
runtime = root / "compositor"
runtime.mkdir(mode=0o700)
backend_runtime = root / "portal"
(backend_runtime / "ferese").mkdir(parents=True, mode=0o700)
config = root / "config/ferese/config.kdl"
config.parent.mkdir(parents=True)
config.write_text("status { 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", DBUS_SYSTEM_BUS_ADDRESS=os.environ["DBUS_SESSION_BUS_ADDRESS"])
env.pop("WAYLAND_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)
destination = runtime / "ferese/control.sock"
wait_for(lambda: destination.exists(), "Nested compositor did not start")
admin = IPC(destination)
outputs = admin.call("get-outputs")
name = next(output["name"] for output in outputs if output["enabled"])
admin.close()
displays = lambda: [path for path in runtime.glob("wayland-*") if not path.name.endswith(".lock")]
wait_for(displays, "Missing nested Wayland socket")
env["WAYLAND_DISPLAY"] = str(displays()[0])
env["XDG_RUNTIME_DIR"] = str(backend_runtime)
proxy = socket.socket(socket.AF_UNIX)
proxy.bind(str(backend_runtime / "ferese/control.sock"))
proxy.listen()
proxy.settimeout(0.1)
def accept():
while not stop.is_set():
try:
client, _ = proxy.accept()
except socket.timeout:
continue
except OSError:
break
threading.Thread(target=proxy_connection, args=(client, destination), daemon=True).start()
threading.Thread(target=accept, daemon=True).start()
bus = Gio.bus_get_sync(Gio.BusType.SESSION, None)
bus.call_sync("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "RequestName", GLib.Variant("(su)", ("org.freedesktop.portal.Desktop", 0)), None, Gio.DBusCallFlags.NONE, 2000, None)
backend = subprocess.Popen([str(REPO / "target/debug/xdg-desktop-portal-ferese")], env=env, stdout=log, stderr=log, start_new_session=True)
processes.append(backend)
def call(method, params=None, path=PATH, interface=INTERFACE):
return bus.call_sync(NAME, path, interface, method, params, None, Gio.DBusCallFlags.NO_AUTO_START, 12000, None).unpack()
def ready():
try:
call("Introspect", interface="org.freedesktop.DBus.Introspectable")
return True
except GLib.Error:
return False
wait_for(ready, "Portal backend did not start")
data = {"app": "org.test.Share", "cursor": False, "mode": 1, "monitors": [{"name": name, "identity": "drm-edid:test-" + name}]}
def create(token, app="org.test.Share", saved=data):
session = PATH + "/session/test/" + token
handle = PATH + "/request/test/" + token
assert call("CreateSession", GLib.Variant("(oosa{sv})", (handle, session, app, {}))) == (0, {})
options = {"persist_mode": GLib.Variant("u", 2), "restore_data": GLib.Variant("(suv)", ("Ferese", 1, GLib.Variant("s", json.dumps(saved))))}
assert call("SelectSources", GLib.Variant("(oosa{sv})", (handle, session, app, options))) == (0, {})
return session, handle
def helpers():
return [pid for children in Path(f"/proc/{backend.pid}/task").glob("*/children") for pid in children.read_text().split()]
session, handle = create("restore")
response, result = call("Start", GLib.Variant("(oossa{sv})", (handle, session, "org.test.Share", "", {})))
assert response == 0 and result["persist_mode"] == 1, (response, result)
assert result["restore_data"][:2] == ("Ferese", 1), result
assert json.loads(result["restore_data"][2])["mode"] == 1
assert len(result["streams"]) == 1
assert all("--picker" not in Path(f"/proc/{pid}/cmdline").read_text() for pid in helpers()), "Restored selection opened a picker"
node = result["streams"][0][0]
pipeline = Gst.parse_launch(f"pipewiresrc path={node} num-buffers=5 ! video/x-raw,format=BGRx ! fakesink sync=false")
try:
pipeline.set_state(Gst.State.PLAYING)
message = pipeline.get_bus().timed_pop_filtered(8 * Gst.SECOND, Gst.MessageType.EOS | Gst.MessageType.ERROR)
assert message and message.type == Gst.MessageType.EOS, "Restored stream did not deliver video"
finally:
pipeline.set_state(Gst.State.NULL)
call("Close", path=session, interface="org.freedesktop.impl.portal.Session")
wait_for(lambda: not helpers(), "Closing restored session left capture children")
print("PASS: wire restore, transient duration retained, video delivery and Close cleanup", flush=True)
def fallback(token, app="org.test.Share", saved=data):
session, handle = create(token, app, saved)
replies = []
def done(connection, result):
try:
replies.append(connection.call_finish(result).unpack())
except GLib.Error as error:
replies.append(error)
bus.call(NAME, PATH, INTERFACE, "Start", GLib.Variant("(oossa{sv})", (handle, session, app, "", {})), None, Gio.DBusCallFlags.NO_AUTO_START, 12000, None, done)
wait_for(lambda: any("--picker" in Path(f"/proc/{pid}/cmdline").read_text() for pid in helpers()), "Invalid restore did not require picker")
call("Close", path=handle, interface="org.freedesktop.impl.portal.Request")
wait_for(lambda: replies and not helpers(), "Cancelled fallback left helpers")
assert replies == [(1, {})], replies
fallback("wrongapp", app="org.other.App")
stale = dict(data, monitors=[{"name": name, "identity": "drm-edid:other-device"}])
fallback("replacement", saved=stale)
print("PASS: other-app and changed-device restore require fresh consent", flush=True)
mismatch = subprocess.Popen([str(REPO / "target/debug/xdg-desktop-portal-ferese"), "--stream", name, "hidden", str(2**32 - 1)], env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, start_new_session=True)
processes.append(mismatch)
mismatch.wait(timeout=8)
error = mismatch.stderr.read()
mismatch.stdin.close()
assert mismatch.returncode != 0 and "replaced" in error, error
session, handle = create("frontendloss")
assert call("Start", GLib.Variant("(oossa{sv})", (handle, session, "org.test.Share", "", {})))[0] == 0
bus.close_sync(None)
wait_for(lambda: not helpers(), "Frontend loss did not revoke restored capture")
print("PASS: output-generation mismatch and frontend disconnect revoke capture", flush=True)
except Exception:
print((root / "test.log").read_text(), file=sys.stderr)
raise
finally:
stop.set()
if proxy:
proxy.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()
log.close()
if __name__ == "__main__":
if "--private" in sys.argv:
private_test()
else:
unittest.main()